├── .gitignore
├── .idea
├── aws.xml
├── codeStyles
│ └── codeStyleConfig.xml
├── dataSources.xml
├── encodings.xml
├── inspectionProfiles
│ └── Project_Default.xml
├── jsLibraryMappings.xml
├── jsLinters
│ └── eslint.xml
├── markdown-exported-files.xml
├── markdown-navigator.xml
├── misc.xml
├── modules.xml
├── prettier.xml
├── react-nodebird.iml
└── vcs.xml
├── README.md
├── ch1
└── front
│ ├── .eslintrc
│ ├── components
│ └── AppLayout.js
│ ├── hooks
│ └── useInput.js
│ ├── package-lock.json
│ ├── package.json
│ └── pages
│ ├── _app.js
│ ├── index.js
│ ├── profile.js
│ └── signup.js
├── ch2
└── front
│ ├── .eslintrc
│ ├── components
│ ├── AppLayout.js
│ ├── CommentForm.js
│ ├── FollowButton.js
│ ├── FollowList.js
│ ├── ImagesZoom
│ │ ├── index.js
│ │ └── styles.js
│ ├── LoginForm.js
│ ├── NicknameEditForm.js
│ ├── PostCard.js
│ ├── PostCardContent.js
│ ├── PostForm.js
│ ├── PostImages.js
│ └── UserProfile.js
│ ├── hooks
│ └── useInput.js
│ ├── package-lock.json
│ ├── package.json
│ └── pages
│ ├── _app.js
│ ├── index.js
│ ├── profile.js
│ └── signup.js
├── ch3
└── front
│ ├── .eslintrc
│ ├── components
│ ├── AppLayout.js
│ ├── CommentForm.js
│ ├── FollowButton.js
│ ├── FollowList.js
│ ├── ImagesZoom
│ │ ├── index.js
│ │ └── styles.js
│ ├── LoginForm.js
│ ├── NicknameEditForm.js
│ ├── PostCard.js
│ ├── PostCardContent.js
│ ├── PostForm.js
│ ├── PostImages.js
│ └── UserProfile.js
│ ├── hooks
│ └── useInput.js
│ ├── package-lock.json
│ ├── package.json
│ ├── pages
│ ├── _app.js
│ ├── index.js
│ ├── profile.js
│ └── signup.js
│ ├── reducers
│ ├── index.js
│ ├── post.js
│ └── user.js
│ └── store
│ └── configureStore.js
├── ch4
└── front
│ ├── .eslintrc
│ ├── components
│ ├── AppLayout.js
│ ├── CommentForm.js
│ ├── FollowButton.js
│ ├── FollowList.js
│ ├── ImagesZoom
│ │ ├── index.js
│ │ └── styles.js
│ ├── LoginForm.js
│ ├── NicknameEditForm.js
│ ├── PostCard.js
│ ├── PostCardContent.js
│ ├── PostForm.js
│ ├── PostImages.js
│ └── UserProfile.js
│ ├── hooks
│ └── useInput.js
│ ├── package-lock.json
│ ├── package.json
│ ├── pages
│ ├── _app.js
│ ├── index.js
│ ├── profile.js
│ └── signup.js
│ ├── reducers
│ ├── index.js
│ ├── post.js
│ └── user.js
│ ├── sagas
│ ├── index.js
│ ├── post.js
│ └── user.js
│ ├── store
│ └── configureStore.js
│ └── util
│ └── produce.js
├── ch5
├── back
│ ├── app.js
│ ├── config
│ │ └── config.js
│ ├── models
│ │ ├── comment.js
│ │ ├── hashtag.js
│ │ ├── image.js
│ │ ├── index.js
│ │ ├── post.js
│ │ └── user.js
│ ├── nodemon.json
│ ├── package-lock.json
│ ├── package.json
│ ├── passport
│ │ ├── index.js
│ │ └── local.js
│ └── routes
│ │ ├── middlewares.js
│ │ ├── post.js
│ │ ├── posts.js
│ │ └── user.js
└── front
│ ├── .eslintrc
│ ├── components
│ ├── AppLayout.js
│ ├── CommentForm.js
│ ├── FollowButton.js
│ ├── FollowList.js
│ ├── ImagesZoom
│ │ ├── index.js
│ │ └── styles.js
│ ├── LoginForm.js
│ ├── NicknameEditForm.js
│ ├── PostCard.js
│ ├── PostCardContent.js
│ ├── PostForm.js
│ ├── PostImages.js
│ └── UserProfile.js
│ ├── hooks
│ └── useInput.js
│ ├── package-lock.json
│ ├── package.json
│ ├── pages
│ ├── _app.js
│ ├── index.js
│ ├── profile.js
│ └── signup.js
│ ├── reducers
│ ├── index.js
│ ├── post.js
│ └── user.js
│ ├── sagas
│ ├── index.js
│ ├── post.js
│ └── user.js
│ ├── store
│ └── configureStore.js
│ └── util
│ └── produce.js
├── ch6
├── back
│ ├── app.js
│ ├── config
│ │ └── config.js
│ ├── models
│ │ ├── comment.js
│ │ ├── hashtag.js
│ │ ├── image.js
│ │ ├── index.js
│ │ ├── post.js
│ │ └── user.js
│ ├── nodemon.json
│ ├── package-lock.json
│ ├── package.json
│ ├── passport
│ │ ├── index.js
│ │ └── local.js
│ └── routes
│ │ ├── hashtag.js
│ │ ├── middlewares.js
│ │ ├── post.js
│ │ ├── posts.js
│ │ └── user.js
└── front
│ ├── .babelrc
│ ├── .eslintrc
│ ├── components
│ ├── AppLayout.js
│ ├── CommentForm.js
│ ├── FollowButton.js
│ ├── FollowList.js
│ ├── ImagesZoom
│ │ ├── index.js
│ │ └── styles.js
│ ├── LoginForm.js
│ ├── NicknameEditForm.js
│ ├── PostCard.js
│ ├── PostCardContent.js
│ ├── PostForm.js
│ ├── PostImages.js
│ └── UserProfile.js
│ ├── config
│ └── config.js
│ ├── hooks
│ └── useInput.js
│ ├── next.config.js
│ ├── package-lock.json
│ ├── package.json
│ ├── pages
│ ├── _app.js
│ ├── _document.js
│ ├── about.js
│ ├── hashtag
│ │ └── [tag].js
│ ├── index.js
│ ├── post
│ │ └── [id].js
│ ├── profile.js
│ ├── signup.js
│ └── user
│ │ └── [id].js
│ ├── public
│ └── favicon.ico
│ ├── reducers
│ ├── index.js
│ ├── post.js
│ └── user.js
│ ├── sagas
│ ├── index.js
│ ├── post.js
│ └── user.js
│ ├── store
│ └── configureStore.js
│ └── util
│ └── produce.js
├── ch7
├── back
│ ├── app.js
│ ├── config
│ │ └── config.js
│ ├── models
│ │ ├── comment.js
│ │ ├── hashtag.js
│ │ ├── image.js
│ │ ├── index.js
│ │ ├── post.js
│ │ └── user.js
│ ├── package-lock.json
│ ├── package.json
│ ├── passport
│ │ ├── index.js
│ │ └── local.js
│ └── routes
│ │ ├── hashtag.js
│ │ ├── middlewares.js
│ │ ├── post.js
│ │ ├── posts.js
│ │ └── user.js
├── front
│ ├── .babelrc
│ ├── .eslintrc
│ ├── components
│ │ ├── AppLayout.js
│ │ ├── CommentForm.js
│ │ ├── FollowButton.js
│ │ ├── FollowList.js
│ │ ├── ImagesZoom
│ │ │ ├── index.js
│ │ │ └── styles.js
│ │ ├── LoginForm.js
│ │ ├── NicknameEditForm.js
│ │ ├── PostCard.js
│ │ ├── PostCardContent.js
│ │ ├── PostForm.js
│ │ ├── PostImages.js
│ │ └── UserProfile.js
│ ├── config
│ │ └── config.js
│ ├── hooks
│ │ └── useInput.js
│ ├── next.config.js
│ ├── package-lock.json
│ ├── package.json
│ ├── pages
│ │ ├── _app.js
│ │ ├── _document.js
│ │ ├── hashtag
│ │ │ └── [tag].js
│ │ ├── index.js
│ │ ├── post
│ │ │ └── [id].js
│ │ ├── profile.js
│ │ ├── signup.js
│ │ └── user
│ │ │ └── [id].js
│ ├── public
│ │ └── favicon.ico
│ ├── reducers
│ │ ├── index.js
│ │ ├── post.js
│ │ └── user.js
│ ├── sagas
│ │ ├── index.js
│ │ ├── post.js
│ │ └── user.js
│ ├── store
│ │ └── configureStore.js
│ └── util
│ │ └── produce.js
└── lambda
│ ├── index.js
│ ├── package-lock.json
│ └── package.json
├── https
├── back
│ ├── app.js
│ ├── config
│ │ └── config.js
│ ├── models
│ │ ├── comment.js
│ │ ├── hashtag.js
│ │ ├── image.js
│ │ ├── index.js
│ │ ├── post.js
│ │ ├── report.js
│ │ └── user.js
│ ├── package-lock.json
│ ├── package.json
│ ├── passport
│ │ ├── index.js
│ │ └── local.js
│ └── routes
│ │ ├── hashtag.js
│ │ ├── middlewares.js
│ │ ├── post.js
│ │ ├── posts.js
│ │ └── user.js
├── front
│ ├── .babelrc
│ ├── .eslintrc
│ ├── components
│ │ ├── AppLayout.js
│ │ ├── CommentForm.js
│ │ ├── FollowButton.js
│ │ ├── FollowList.js
│ │ ├── ImagesZoom
│ │ │ ├── index.js
│ │ │ └── styles.js
│ │ ├── LoginForm.js
│ │ ├── NicknameEditForm.js
│ │ ├── PostCard.js
│ │ ├── PostCardContent.js
│ │ ├── PostForm.js
│ │ ├── PostImages.js
│ │ └── UserProfile.js
│ ├── config
│ │ └── config.js
│ ├── hooks
│ │ └── useInput.js
│ ├── next.config.js
│ ├── package-lock.json
│ ├── package.json
│ ├── pages
│ │ ├── _app.js
│ │ ├── _document.js
│ │ ├── hashtag
│ │ │ └── [tag].js
│ │ ├── index.js
│ │ ├── post
│ │ │ └── [id].js
│ │ ├── profile.js
│ │ ├── signup.js
│ │ └── user
│ │ │ └── [id].js
│ ├── public
│ │ └── favicon.ico
│ ├── reducers
│ │ ├── index.js
│ │ ├── post.js
│ │ └── user.js
│ ├── sagas
│ │ ├── index.js
│ │ ├── post.js
│ │ └── user.js
│ ├── store
│ │ └── configureStore.js
│ └── util
│ │ └── produce.js
└── lambda
│ ├── index.js
│ ├── package-lock.json
│ └── package.json
├── intersection
├── back
│ ├── app.js
│ ├── config
│ │ └── config.js
│ ├── models
│ │ ├── comment.js
│ │ ├── hashtag.js
│ │ ├── image.js
│ │ ├── index.js
│ │ ├── post.js
│ │ └── user.js
│ ├── package-lock.json
│ ├── package.json
│ ├── passport
│ │ ├── index.js
│ │ └── local.js
│ └── routes
│ │ ├── hashtag.js
│ │ ├── middlewares.js
│ │ ├── post.js
│ │ ├── posts.js
│ │ └── user.js
├── front
│ ├── .babelrc
│ ├── .eslintrc
│ ├── components
│ │ ├── AppLayout.js
│ │ ├── CommentForm.js
│ │ ├── FollowButton.js
│ │ ├── FollowList.js
│ │ ├── ImagesZoom
│ │ │ ├── index.js
│ │ │ └── styles.js
│ │ ├── LoginForm.js
│ │ ├── NicknameEditForm.js
│ │ ├── PostCard.js
│ │ ├── PostCardContent.js
│ │ ├── PostForm.js
│ │ ├── PostImages.js
│ │ └── UserProfile.js
│ ├── config
│ │ └── config.js
│ ├── hooks
│ │ └── useInput.js
│ ├── next.config.js
│ ├── package-lock.json
│ ├── package.json
│ ├── pages
│ │ ├── _app.js
│ │ ├── _document.js
│ │ ├── hashtag
│ │ │ └── [tag].js
│ │ ├── index.js
│ │ ├── post
│ │ │ └── [id].js
│ │ ├── profile.js
│ │ ├── signup.js
│ │ └── user
│ │ │ └── [id].js
│ ├── public
│ │ └── favicon.ico
│ ├── reducers
│ │ ├── index.js
│ │ ├── post.js
│ │ └── user.js
│ ├── sagas
│ │ ├── index.js
│ │ ├── post.js
│ │ └── user.js
│ ├── store
│ │ └── configureStore.js
│ └── util
│ │ └── produce.js
└── lambda
│ ├── index.js
│ ├── package-lock.json
│ └── package.json
├── react-query
├── back
│ ├── app.js
│ ├── config
│ │ └── config.js
│ ├── models
│ │ ├── comment.js
│ │ ├── hashtag.js
│ │ ├── image.js
│ │ ├── index.js
│ │ ├── post.js
│ │ └── user.js
│ ├── package-lock.json
│ ├── package.json
│ ├── passport
│ │ ├── index.js
│ │ └── local.js
│ └── routes
│ │ ├── hashtag.js
│ │ ├── middlewares.js
│ │ ├── post.js
│ │ ├── posts.js
│ │ └── user.js
├── front
│ ├── .babelrc
│ ├── .eslintrc
│ ├── .prettierrc
│ ├── apis
│ │ ├── post.ts
│ │ └── user.ts
│ ├── components
│ │ ├── AppLayout.tsx
│ │ ├── CommentForm.tsx
│ │ ├── FollowButton.tsx
│ │ ├── FollowList.tsx
│ │ ├── ImagesZoom
│ │ │ ├── index.tsx
│ │ │ └── styles.ts
│ │ ├── LoginForm.tsx
│ │ ├── NicknameEditForm.tsx
│ │ ├── PostCard.tsx
│ │ ├── PostCardContent.tsx
│ │ ├── PostForm.tsx
│ │ ├── PostImages.tsx
│ │ └── UserProfile.tsx
│ ├── config
│ │ └── config.ts
│ ├── hooks
│ │ └── useInput.ts
│ ├── interfaces
│ │ ├── comment.ts
│ │ ├── post.ts
│ │ └── user.ts
│ ├── next-env.d.ts
│ ├── next.config.js
│ ├── package-lock.json
│ ├── package.json
│ ├── pages
│ │ ├── _app.tsx
│ │ ├── _document.tsx
│ │ ├── error
│ │ │ └── notFound.tsx
│ │ ├── hashtag
│ │ │ └── [tag].tsx
│ │ ├── index.tsx
│ │ ├── post
│ │ │ └── [id].tsx
│ │ ├── profile.tsx
│ │ ├── signup.tsx
│ │ └── user
│ │ │ └── [id].tsx
│ ├── public
│ │ └── favicon.ico
│ ├── tsconfig.json
│ └── utils
│ │ └── produce.ts
├── lambda
│ ├── index.js
│ ├── package-lock.json
│ └── package.json
└── react-nodebird-admin
│ ├── .dockerignore
│ ├── .forestadmin-schema.json
│ ├── .gitignore
│ ├── Dockerfile
│ ├── app.js
│ ├── docker-compose.yml
│ ├── forest
│ ├── comments.js
│ ├── follow.js
│ ├── hashtags.js
│ ├── images.js
│ ├── like.js
│ ├── post-hashtag.js
│ ├── posts.js
│ ├── retweet.js
│ └── users.js
│ ├── middlewares
│ ├── forestadmin.js
│ └── welcome.js
│ ├── models
│ ├── comments.js
│ ├── follow.js
│ ├── hashtags.js
│ ├── images.js
│ ├── index.js
│ ├── like.js
│ ├── post-hashtag.js
│ ├── posts.js
│ ├── retweet.js
│ └── users.js
│ ├── package-lock.json
│ ├── package.json
│ ├── public
│ └── favicon.png
│ ├── routes
│ ├── comments.js
│ ├── follow.js
│ ├── hashtags.js
│ ├── images.js
│ ├── like.js
│ ├── post-hashtag.js
│ ├── posts.js
│ ├── retweet.js
│ └── users.js
│ ├── server.js
│ └── views
│ └── index.html
├── toolkit
├── back
│ ├── app.js
│ ├── config
│ │ └── config.js
│ ├── models
│ │ ├── comment.js
│ │ ├── hashtag.js
│ │ ├── image.js
│ │ ├── index.js
│ │ ├── post.js
│ │ └── user.js
│ ├── package-lock.json
│ ├── package.json
│ ├── passport
│ │ ├── index.js
│ │ └── local.js
│ └── routes
│ │ ├── hashtag.js
│ │ ├── middlewares.js
│ │ ├── post.js
│ │ ├── posts.js
│ │ └── user.js
└── front
│ ├── .eslintrc
│ ├── components
│ ├── AppLayout.js
│ ├── CommentForm.js
│ ├── FollowButton.js
│ ├── FollowList.js
│ ├── ImagesZoom
│ │ ├── index.js
│ │ └── styles.js
│ ├── LoginForm.js
│ ├── NicknameEditForm.js
│ ├── PostCard.js
│ ├── PostCardContent.js
│ ├── PostForm.js
│ ├── PostImages.js
│ └── UserProfile.js
│ ├── config
│ └── config.js
│ ├── hooks
│ └── useInput.js
│ ├── next.config.js
│ ├── package-lock.json
│ ├── package.json
│ ├── pages
│ ├── _app.js
│ ├── _document.js
│ ├── hashtag
│ │ └── [tag].js
│ ├── index.js
│ ├── post
│ │ └── [id].js
│ ├── profile.js
│ ├── signup.js
│ └── user
│ │ └── [id].js
│ ├── public
│ └── favicon.ico
│ ├── reducers
│ ├── index.js
│ ├── post.js
│ └── user.js
│ └── store
│ └── configureStore.js
└── updater.js
/.idea/aws.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/dataSources.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | mariadb
6 | true
7 | org.mariadb.jdbc.Driver
8 | jdbc:mariadb://localhost:3306
9 | $ProjectFileDir$
10 |
11 |
12 |
--------------------------------------------------------------------------------
/.idea/encodings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/jsLibraryMappings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/.idea/jsLinters/eslint.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/markdown-exported-files.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/.idea/modules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.idea/prettier.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/.idea/react-nodebird.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 구버전 강좌는 old branch에 있습니다.
2 | 리뉴얼 강좌 소스 코드는 master 브랜치에 있습니다.
3 | 리뉴얼 강좌의 ch7는 prepare 폴더입니다.
4 |
5 | toolkit 적용하고 싶으신 분들을 위해 toolkit 폴더에 소스 코드 정리해두었습니다.
6 | Credits to [소라연](https://github.com/sorayeon/react-nodebird-toolkit)
7 |
8 | 버그가 있을 시 인프런이나 깃헙 issue로 남겨주시면 빠르게 해결하겠습니다.
9 |
10 | [https://nodebird.com](https://nodebird.com)에서 실제 실행 결과물을 확인하실 수 있습니다.
11 |
12 |
--------------------------------------------------------------------------------
/ch1/front/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parserOptions": {
3 | "ecmaVersion": 2020,
4 | "sourceType": "module",
5 | "ecmaFeatures": {
6 | "jsx": true
7 | }
8 | },
9 | "env": {
10 | "browser": true,
11 | "node": true
12 | },
13 | "extends": [
14 | "eslint:recommended",
15 | "plugin:react/recommended"
16 | ],
17 | "plugins": [
18 | "import",
19 | "react-hooks"
20 | ],
21 | "rules": {
22 | "jsx-a11y/label-has-associated-control": "off",
23 | "jsx-a11y/anchor-is-valid": "off"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/ch1/front/components/AppLayout.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Link from 'next/link';
3 | import { Menu, Input, Button } from 'antd';
4 | import PropTypes from 'prop-types';
5 |
6 | const AppLayout = ({ children }) => {
7 | return (
8 |
9 |
16 |
17 |
18 |
19 | {children}
20 |
21 | );
22 | };
23 | AppLayout.propTypes = {
24 | children: PropTypes.node.isRequired,
25 | }
26 |
27 | export default AppLayout;
28 |
--------------------------------------------------------------------------------
/ch1/front/hooks/useInput.js:
--------------------------------------------------------------------------------
1 | import { useState, useCallback } from 'react';
2 |
3 | export default (initValue = null) => {
4 | const [value, setter] = useState(initValue);
5 | const handler = useCallback((e) => {
6 | setter(e.target.value);
7 | }, []);
8 | return [value, handler];
9 | };
10 |
--------------------------------------------------------------------------------
/ch1/front/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-nodebird-front",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "dev": "next -p 3060",
8 | "build": "next build",
9 | "start": "next start"
10 | },
11 | "author": "ZeroCho",
12 | "license": "MIT",
13 | "dependencies": {
14 | "antd": "^4.2.5",
15 | "next": "^12.3.4",
16 | "prop-types": "^15.7.2",
17 | "react": "^17.0.2",
18 | "react-dom": "^17.0.2"
19 | },
20 | "devDependencies": {
21 | "eslint": "^7.1.0",
22 | "eslint-plugin-import": "^2.20.2",
23 | "eslint-plugin-react": "^7.20.0",
24 | "eslint-plugin-react-hooks": "^4.0.4",
25 | "nodemon": "^2.0.4",
26 | "webpack": "^5.76.3"
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/ch1/front/pages/_app.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Head from 'next/head';
3 | import PropTypes from 'prop-types';
4 | import 'antd/dist/antd.css';
5 |
6 | const NodeBird = ({ Component }) => {
7 | return (
8 | <>
9 |
10 | NodeBird
11 |
12 |
13 | >
14 | );
15 | };
16 |
17 | NodeBird.propTypes = {
18 | Component: PropTypes.elementType.isRequired,
19 | };
20 |
21 | export default NodeBird;
22 |
--------------------------------------------------------------------------------
/ch1/front/pages/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Head from 'next/head';
3 | import AppLayout from '../components/AppLayout';
4 |
5 | const Home = () => (
6 |
7 |
8 | NodeBird
9 |
10 | Hello, Next!
11 |
12 | );
13 |
14 | export default Home;
15 |
--------------------------------------------------------------------------------
/ch1/front/pages/profile.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Head from 'next/head';
3 |
4 | import AppLayout from '../components/AppLayout';
5 |
6 | const Profile = () => (
7 |
8 |
9 | NodeBird
10 |
11 | 내 프로필
12 |
13 | );
14 |
15 | export default Profile;
16 |
--------------------------------------------------------------------------------
/ch2/front/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parserOptions": {
3 | "ecmaVersion": 2020,
4 | "sourceType": "module",
5 | "ecmaFeatures": {
6 | "jsx": true
7 | }
8 | },
9 | "env": {
10 | "browser": true,
11 | "node": true
12 | },
13 | "extends": [
14 | "eslint:recommended",
15 | "plugin:react/recommended"
16 | ],
17 | "plugins": [
18 | "import",
19 | "react-hooks"
20 | ],
21 | "rules": {
22 | "jsx-a11y/label-has-associated-control": "off",
23 | "jsx-a11y/anchor-is-valid": "off",
24 | "no-console": "off"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/ch2/front/components/CommentForm.js:
--------------------------------------------------------------------------------
1 | import { Button, Form, Input } from 'antd';
2 | import React, { useCallback, useState } from 'react';
3 | import PropTypes from 'prop-types';
4 |
5 | const CommentForm = ({ post }) => {
6 | const [commentText, setCommentText] = useState('');
7 |
8 | const onSubmitComment = useCallback(() => {
9 | console.log(commentText);
10 | }, [commentText]);
11 |
12 | const onChangeCommentText = useCallback((e) => {
13 | setCommentText(e.target.value);
14 | }, []);
15 |
16 | return (
17 |
19 |
20 |
21 |
22 |
23 | );
24 | };
25 |
26 | CommentForm.propTypes = {
27 | post: PropTypes.object.isRequired,
28 | };
29 |
30 | export default CommentForm;
31 |
--------------------------------------------------------------------------------
/ch2/front/components/FollowButton.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Button } from 'antd';
3 | import PropTypes from 'prop-types';
4 |
5 | const FollowButton = ({ post }) => {
6 | return ;
7 | };
8 |
9 | FollowButton.propTypes = {
10 | post: PropTypes.object.isRequired,
11 | };
12 |
13 | export default FollowButton;
14 |
--------------------------------------------------------------------------------
/ch2/front/components/FollowList.js:
--------------------------------------------------------------------------------
1 | import { Button, Card, List } from 'antd';
2 | import { StopOutlined } from '@ant-design/icons';
3 | import React from 'react';
4 | import PropTypes from 'prop-types';
5 |
6 | const FollowList = ({ header, data }) => (
7 | {header}}
12 | loadMore={}
13 | bordered
14 | dataSource={data}
15 | renderItem={(item) => (
16 |
17 | ]}>
18 |
19 |
20 |
21 | )}
22 | />
23 | );
24 |
25 | FollowList.propTypes = {
26 | header: PropTypes.string.isRequired,
27 | data: PropTypes.array.isRequired,
28 | };
29 |
30 | export default FollowList;
31 |
--------------------------------------------------------------------------------
/ch2/front/components/LoginForm.js:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from 'react';
2 | import { Button, Form, Input } from 'antd';
3 | import Link from 'next/link';
4 |
5 | import useInput from '../hooks/useInput';
6 |
7 | const LoginForm = () => {
8 | const [id, onChangeId] = useInput('');
9 | const [password, onChangePassword] = useInput('');
10 | const onSubmitForm = useCallback(() => {
11 | console.log({
12 | id, password,
13 | });
14 | }, [id, password]);
15 |
16 | return (
17 |
33 | );
34 | };
35 |
36 | export default LoginForm;
37 |
--------------------------------------------------------------------------------
/ch2/front/components/NicknameEditForm.js:
--------------------------------------------------------------------------------
1 | import { Form, Input } from 'antd';
2 | import React from 'react';
3 |
4 | const NicknameEditForm = () => {
5 | return (
6 |
9 | );
10 | };
11 |
12 | export default NicknameEditForm;
13 |
--------------------------------------------------------------------------------
/ch2/front/components/PostCardContent.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Link from 'next/link';
3 | import PropTypes from 'prop-types';
4 |
5 | const PostCardContent = ({ postData }) => (
6 |
7 | {postData.split(/(#[^\s]+)/g).map((v) => {
8 | if (v.match(/#[^\s]+/)) {
9 | return (
10 |
15 |
{v}
16 |
17 | );
18 | }
19 | return v;
20 | })}
21 |
22 | );
23 |
24 | PostCardContent.propTypes = {
25 | postData: PropTypes.string.isRequired,
26 | };
27 |
28 | export default PostCardContent;
29 |
--------------------------------------------------------------------------------
/ch2/front/components/UserProfile.js:
--------------------------------------------------------------------------------
1 | import { Avatar, Card, Button } from 'antd';
2 | import React from 'react';
3 |
4 | const dummy = {
5 | nickname: '제로초',
6 | Posts: [],
7 | Followings: [],
8 | Followers: [],
9 | isLoggedIn: false,
10 | };
11 |
12 | const UserProfile = () => {
13 | return (
14 | 짹짹
{dummy.Posts.length},
17 | 팔로잉
{dummy.Followings.length}
,
18 | 팔로워
{dummy.Followers.length}
,
19 | ]}
20 | >
21 | {dummy.nickname[0]}}
23 | title={dummy.nickname}
24 | />
25 |
26 |
27 | );
28 | };
29 |
30 | export default UserProfile;
31 |
--------------------------------------------------------------------------------
/ch2/front/hooks/useInput.js:
--------------------------------------------------------------------------------
1 | import { useState, useCallback } from 'react';
2 |
3 | export default (initValue = null) => {
4 | const [value, setter] = useState(initValue);
5 | const handler = useCallback((e) => {
6 | setter(e.target.value);
7 | }, []);
8 | return [value, handler];
9 | };
10 |
--------------------------------------------------------------------------------
/ch2/front/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-nodebird-front",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "dev": "next -p 3060",
8 | "build": "next build",
9 | "start": "next start"
10 | },
11 | "author": "ZeroCho",
12 | "license": "MIT",
13 | "dependencies": {
14 | "@ant-design/icons": "^4.2.2",
15 | "antd": "^4.6.6",
16 | "next": "^12.0.7",
17 | "prop-types": "^15.7.2",
18 | "react": "^17.0.2",
19 | "react-dom": "^17.0.2",
20 | "react-slick": "^0.28.1",
21 | "styled-components": "^5.2.0"
22 | },
23 | "devDependencies": {
24 | "eslint": "^7.10.0",
25 | "eslint-plugin-import": "^2.22.1",
26 | "eslint-plugin-react": "^7.21.3",
27 | "eslint-plugin-react-hooks": "^4.1.2",
28 | "nodemon": "^2.0.4",
29 | "webpack": "^5.65.0"
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/ch2/front/pages/_app.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Head from 'next/head';
3 | import PropTypes from 'prop-types';
4 | import 'antd/dist/antd.css';
5 |
6 | const NodeBird = ({ Component }) => {
7 | return (
8 | <>
9 |
10 | NodeBird
11 |
12 |
13 | >
14 | );
15 | };
16 |
17 | NodeBird.propTypes = {
18 | Component: PropTypes.elementType.isRequired,
19 | };
20 |
21 | export default NodeBird;
22 |
--------------------------------------------------------------------------------
/ch2/front/pages/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PostForm from '../components/PostForm';
3 | import PostCard from '../components/PostCard';
4 | import AppLayout from '../components/AppLayout';
5 |
6 | const dummy = {
7 | isLoggedIn: true,
8 | imagePaths: [],
9 | mainPosts: [{
10 | id: 1,
11 | User: {
12 | id: 1,
13 | nickname: '제로초',
14 | },
15 | content: '첫 번째 게시글',
16 | Images: [{
17 | src: 'https://bookthumb-phinf.pstatic.net/cover/137/995/13799585.jpg?udate=20180726',
18 | }, {
19 | src: 'https://gimg.gilbut.co.kr/book/BN001958/rn_view_BN001958.jpg',
20 | }, {
21 | src: 'https://gimg.gilbut.co.kr/book/BN001998/rn_view_BN001998.jpg',
22 | }]
23 | }],
24 | };
25 |
26 | const Home = () => {
27 | return (
28 |
29 | {dummy.isLoggedIn && }
30 | {dummy.mainPosts.map((c) => {
31 | return (
32 |
33 | );
34 | })}
35 |
36 | );
37 | };
38 |
39 | export default Home;
40 |
--------------------------------------------------------------------------------
/ch2/front/pages/profile.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import NicknameEditForm from '../components/NicknameEditForm';
4 | import AppLayout from '../components/AppLayout';
5 | import FollowList from '../components/FollowList';
6 |
7 | const Profile = () => {
8 | const followerList = [{ nickname: '제로초' }, { nickname: '바보' }, { nickname: '노드버드오피셜' }];
9 | const followingList = [{ nickname: '제로초' }, { nickname: '바보' }, { nickname: '노드버드오피셜' }];
10 |
11 | return (
12 |
13 |
14 |
18 |
22 |
23 | );
24 | };
25 |
26 | export default Profile;
27 |
--------------------------------------------------------------------------------
/ch3/front/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parserOptions": {
3 | "ecmaVersion": 2020,
4 | "sourceType": "module",
5 | "ecmaFeatures": {
6 | "jsx": true
7 | }
8 | },
9 | "env": {
10 | "browser": true,
11 | "node": true
12 | },
13 | "extends": [
14 | "eslint:recommended",
15 | "plugin:react/recommended"
16 | ],
17 | "plugins": [
18 | "import",
19 | "react-hooks"
20 | ],
21 | "rules": {
22 | "jsx-a11y/label-has-associated-control": "off",
23 | "jsx-a11y/anchor-is-valid": "off",
24 | "no-console": "off"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/ch3/front/components/CommentForm.js:
--------------------------------------------------------------------------------
1 | import { Button, Form, Input } from 'antd';
2 | import React, { useCallback, useState } from 'react';
3 | import PropTypes from 'prop-types';
4 |
5 | const CommentForm = ({ post }) => {
6 | const [commentText, setCommentText] = useState('');
7 |
8 | const onSubmitComment = useCallback(() => {
9 | console.log(commentText);
10 | }, [commentText]);
11 |
12 | const onChangeCommentText = useCallback((e) => {
13 | setCommentText(e.target.value);
14 | }, []);
15 |
16 | return (
17 |
19 |
20 |
21 |
22 |
23 | );
24 | };
25 |
26 | CommentForm.propTypes = {
27 | post: PropTypes.object.isRequired,
28 | };
29 |
30 | export default CommentForm;
31 |
--------------------------------------------------------------------------------
/ch3/front/components/FollowButton.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Button } from 'antd';
3 | import PropTypes from 'prop-types';
4 |
5 | const FollowButton = ({ post }) => {
6 | return ;
7 | };
8 |
9 | FollowButton.propTypes = {
10 | post: PropTypes.object.isRequired,
11 | };
12 |
13 | export default FollowButton;
14 |
--------------------------------------------------------------------------------
/ch3/front/components/FollowList.js:
--------------------------------------------------------------------------------
1 | import { Button, Card, List } from 'antd';
2 | import { StopOutlined } from '@ant-design/icons';
3 | import React from 'react';
4 | import PropTypes from 'prop-types';
5 |
6 | const FollowList = ({ header, data }) => (
7 | {header}}
12 | loadMore={}
13 | bordered
14 | dataSource={data}
15 | renderItem={(item) => (
16 |
17 | ]}>
18 |
19 |
20 |
21 | )}
22 | />
23 | );
24 |
25 | FollowList.propTypes = {
26 | header: PropTypes.string.isRequired,
27 | data: PropTypes.array.isRequired,
28 | };
29 |
30 | export default FollowList;
31 |
--------------------------------------------------------------------------------
/ch3/front/components/NicknameEditForm.js:
--------------------------------------------------------------------------------
1 | import { Form, Input } from 'antd';
2 | import React from 'react';
3 |
4 | const NicknameEditForm = () => {
5 | return (
6 |
9 | );
10 | };
11 |
12 | export default NicknameEditForm;
13 |
--------------------------------------------------------------------------------
/ch3/front/components/PostCardContent.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Link from 'next/link';
3 | import PropTypes from 'prop-types';
4 |
5 | const PostCardContent = ({ postData }) => (
6 |
7 | {postData.split(/(#[^\s#]+)/g).map((v) => {
8 | if (v.match(/(#[^\s]+)/)) {
9 | return (
10 |
15 |
{v}
16 |
17 | );
18 | }
19 | return v;
20 | })}
21 |
22 | );
23 |
24 | PostCardContent.propTypes = {
25 | postData: PropTypes.string.isRequired,
26 | };
27 |
28 | export default PostCardContent;
29 |
--------------------------------------------------------------------------------
/ch3/front/components/UserProfile.js:
--------------------------------------------------------------------------------
1 | import { Avatar, Card, Button } from 'antd';
2 | import React, { useCallback } from 'react';
3 | import { useSelector, useDispatch } from 'react-redux';
4 |
5 | import { logoutAction } from '../reducers/user';
6 |
7 | const UserProfile = () => {
8 | const { user } = useSelector(state => state.user);
9 | const dispatch = useDispatch();
10 |
11 | const onLogout = useCallback(() => {
12 | dispatch(logoutAction);
13 | }, []);
14 |
15 | return (
16 | 짹짹
{user.Posts.length},
19 | 팔로잉
{user.Followings.length}
,
20 | 팔로워
{user.Followers.length}
,
21 | ]}
22 | >
23 | {user.nickname[0]}}
25 | title={user.nickname}
26 | />
27 |
28 |
29 | );
30 | };
31 |
32 | export default UserProfile;
33 |
--------------------------------------------------------------------------------
/ch3/front/hooks/useInput.js:
--------------------------------------------------------------------------------
1 | import { useState, useCallback } from 'react';
2 |
3 | export default (initValue = null) => {
4 | const [value, setter] = useState(initValue);
5 | const handler = useCallback((e) => {
6 | setter(e.target.value);
7 | }, []);
8 | return [value, handler];
9 | };
10 |
--------------------------------------------------------------------------------
/ch3/front/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-nodebird-front",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "dev": "next -p 3060",
8 | "build": "next build",
9 | "start": "next start"
10 | },
11 | "author": "ZeroCho",
12 | "license": "MIT",
13 | "dependencies": {
14 | "antd": "^4.6.6",
15 | "next": "^12.3.4",
16 | "next-redux-wrapper": "^6.0.2",
17 | "prop-types": "^15.7.2",
18 | "react": "^17.0.2",
19 | "react-dom": "^17.0.2",
20 | "react-redux": "^8.0.5",
21 | "react-slick": "^0.28.1",
22 | "redux": "^4.0.5",
23 | "redux-devtools-extension": "^2.13.8",
24 | "styled-components": "^5.2.0"
25 | },
26 | "devDependencies": {
27 | "eslint": "^7.10.0",
28 | "eslint-plugin-import": "^2.22.1",
29 | "eslint-plugin-react": "^7.21.3",
30 | "eslint-plugin-react-hooks": "^4.1.2",
31 | "nodemon": "^2.0.4",
32 | "webpack": "^5.65.0"
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/ch3/front/pages/_app.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Head from 'next/head';
3 | import PropTypes from 'prop-types';
4 | import 'antd/dist/antd.css';
5 |
6 | import wrapper from '../store/configureStore';
7 |
8 | const NodeBird = ({ Component }) => {
9 | return (
10 | <>
11 |
12 | NodeBird
13 |
14 |
15 | >
16 | );
17 | };
18 |
19 | NodeBird.propTypes = {
20 | Component: PropTypes.elementType.isRequired,
21 | };
22 |
23 | export default wrapper.withRedux(NodeBird);
24 |
--------------------------------------------------------------------------------
/ch3/front/pages/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useSelector } from 'react-redux';
3 |
4 | import PostForm from '../components/PostForm';
5 | import PostCard from '../components/PostCard';
6 | import AppLayout from '../components/AppLayout';
7 |
8 | const Home = () => {
9 | const { isLoggedIn } = useSelector(state => state.user);
10 | const { mainPosts } = useSelector(state => state.post);
11 |
12 | return (
13 |
14 | {isLoggedIn && }
15 | {mainPosts.map((c) => {
16 | return (
17 |
18 | );
19 | })}
20 |
21 | );
22 | };
23 |
24 | export default Home;
25 |
--------------------------------------------------------------------------------
/ch3/front/pages/profile.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import Router from 'next/router';
3 | import { useSelector } from 'react-redux';
4 | import Head from 'next/head';
5 |
6 | import NicknameEditForm from '../components/NicknameEditForm';
7 | import AppLayout from '../components/AppLayout';
8 | import FollowList from '../components/FollowList';
9 |
10 | const Profile = () => {
11 | const { isLoggedIn } = useSelector(state => state.user);
12 |
13 | useEffect(() => {
14 | if (!isLoggedIn) {
15 | Router.replace('/');
16 | }
17 | }, [isLoggedIn])
18 |
19 | const followerList = [{ nickname: '제로초' }, { nickname: '바보' }, { nickname: '노드버드오피셜' }];
20 | const followingList = [{ nickname: '제로초' }, { nickname: '바보' }, { nickname: '노드버드오피셜' }];
21 |
22 | return (
23 |
24 |
25 | 내 프로필 | NodeBird
26 |
27 |
28 |
32 |
36 |
37 | );
38 | };
39 |
40 | export default Profile;
41 |
--------------------------------------------------------------------------------
/ch3/front/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { HYDRATE } from 'next-redux-wrapper';
2 | import { combineReducers } from 'redux';
3 |
4 | import user from './user';
5 | import post from './post';
6 |
7 | const rootReducer = combineReducers({
8 | index: (state = {}, action) => {
9 | switch (action.type) {
10 | case HYDRATE:
11 | console.log('HYDRATE', action);
12 | return { ...state, ...action.payload };
13 | default:
14 | return state;
15 | }
16 | },
17 | user,
18 | post,
19 | });
20 |
21 | export default rootReducer;
22 |
--------------------------------------------------------------------------------
/ch3/front/store/configureStore.js:
--------------------------------------------------------------------------------
1 | import { applyMiddleware, createStore, compose } from 'redux';
2 | import { createWrapper } from 'next-redux-wrapper';
3 | import { composeWithDevTools } from 'redux-devtools-extension';
4 |
5 | import reducer from '../reducers';
6 |
7 | const configureStore = (context) => {
8 | console.log(context);
9 | const middlewares = [];
10 | const enhancer = process.env.NODE_ENV === 'production'
11 | ? compose(applyMiddleware(...middlewares))
12 | : composeWithDevTools(
13 | applyMiddleware(...middlewares),
14 | );
15 | const store = createStore(reducer, enhancer);
16 | return store;
17 | };
18 |
19 | const wrapper = createWrapper(configureStore, { debug: process.env.NODE_ENV === 'development' });
20 |
21 | export default wrapper;
22 |
--------------------------------------------------------------------------------
/ch4/front/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "babel-eslint",
3 | "parserOptions": {
4 | "ecmaVersion": 2020,
5 | "sourceType": "module",
6 | "ecmaFeatures": {
7 | "jsx": true
8 | }
9 | },
10 | "env": {
11 | "browser": true,
12 | "node": true,
13 | "es6": true
14 | },
15 | "extends": [
16 | "airbnb"
17 | ],
18 | "plugins": [
19 | "import",
20 | "react-hooks"
21 | ],
22 | "rules": {
23 | "jsx-a11y/label-has-associated-control": "off",
24 | "jsx-a11y/anchor-is-valid": "off",
25 | "no-console": "off",
26 | "no-underscore-dangle": "off",
27 | "react/forbid-prop-types": "off",
28 | "react/jsx-filename-extension": "off",
29 | "react/jsx-one-expression-per-line": "off",
30 | "object-curly-newline": "off",
31 | "linebreak-style": "off",
32 | "no-param-reassign": "off"
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/ch4/front/components/AppLayout.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Link from 'next/link';
3 | import PropTypes from 'prop-types';
4 | import { Col, Input, Menu, Row } from 'antd';
5 | import { useSelector } from 'react-redux';
6 |
7 | import LoginForm from './LoginForm';
8 | import UserProfile from './UserProfile';
9 |
10 | const AppLayout = ({ children }) => {
11 | const { me } = useSelector((state) => state.user);
12 | return (
13 |
14 |
21 |
22 |
23 | {me
24 | ?
25 | : }
26 |
27 |
28 | {children}
29 |
30 |
31 | Made by ZeroCho
32 |
33 |
34 |
35 | );
36 | };
37 |
38 | AppLayout.propTypes = {
39 | children: PropTypes.node.isRequired,
40 | };
41 |
42 | export default AppLayout;
43 |
--------------------------------------------------------------------------------
/ch4/front/components/FollowButton.js:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from 'react';
2 | import { Button } from 'antd';
3 | import PropTypes from 'prop-types';
4 | import { useSelector, useDispatch } from 'react-redux';
5 | import { FOLLOW_REQUEST, UNFOLLOW_REQUEST } from '../reducers/user';
6 |
7 | const FollowButton = ({ post }) => {
8 | const dispatch = useDispatch();
9 | const { me, followLoading, unfollowLoading } = useSelector((state) => state.user);
10 | const isFollowing = me?.Followings.find((v) => v.id === post.User.id);
11 | const onClickButton = useCallback(() => {
12 | if (isFollowing) {
13 | dispatch({
14 | type: UNFOLLOW_REQUEST,
15 | data: post.User.id,
16 | });
17 | } else {
18 | dispatch({
19 | type: FOLLOW_REQUEST,
20 | data: post.User.id,
21 | });
22 | }
23 | }, [isFollowing]);
24 |
25 | return (
26 |
29 | );
30 | };
31 |
32 | FollowButton.propTypes = {
33 | post: PropTypes.object.isRequired,
34 | };
35 |
36 | export default FollowButton;
37 |
--------------------------------------------------------------------------------
/ch4/front/components/FollowList.js:
--------------------------------------------------------------------------------
1 | import { Button, Card, List } from 'antd';
2 | import { StopOutlined } from '@ant-design/icons';
3 | import React from 'react';
4 | import PropTypes from 'prop-types';
5 |
6 | const FollowList = ({ header, data }) => (
7 | {header}}
12 | loadMore={}
13 | bordered
14 | dataSource={data}
15 | renderItem={(item) => (
16 |
17 | ]}>
18 |
19 |
20 |
21 | )}
22 | />
23 | );
24 |
25 | FollowList.propTypes = {
26 | header: PropTypes.string.isRequired,
27 | data: PropTypes.array.isRequired,
28 | };
29 |
30 | export default FollowList;
31 |
--------------------------------------------------------------------------------
/ch4/front/components/NicknameEditForm.js:
--------------------------------------------------------------------------------
1 | import { Form, Input } from 'antd';
2 | import React, { useCallback } from 'react';
3 | import { useSelector, useDispatch } from 'react-redux';
4 |
5 | import useInput from '../hooks/useInput';
6 | import { CHANGE_NICKNAME_REQUEST } from '../reducers/user';
7 |
8 | const NicknameEditForm = () => {
9 | const { me } = useSelector((state) => state.user);
10 | const [nickname, onChangeNickname] = useInput(me?.nickname || '');
11 | const dispatch = useDispatch();
12 |
13 | const onSubmit = useCallback(() => {
14 | dispatch({
15 | type: CHANGE_NICKNAME_REQUEST,
16 | data: nickname,
17 | });
18 | }, [nickname]);
19 |
20 | return (
21 |
32 | );
33 | };
34 |
35 | export default NicknameEditForm;
36 |
--------------------------------------------------------------------------------
/ch4/front/components/PostCardContent.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Link from 'next/link';
3 | import PropTypes from 'prop-types';
4 |
5 | const PostCardContent = ({ postData }) => (
6 |
7 | {postData.split(/(#[^\s#]+)/g).map((v) => {
8 | if (v.match(/(#[^\s#]+)/)) {
9 | return (
10 |
15 |
{v}
16 |
17 | );
18 | }
19 | return v;
20 | })}
21 |
22 | );
23 |
24 | PostCardContent.propTypes = {
25 | postData: PropTypes.string.isRequired,
26 | };
27 |
28 | export default PostCardContent;
29 |
--------------------------------------------------------------------------------
/ch4/front/components/UserProfile.js:
--------------------------------------------------------------------------------
1 | import { Avatar, Card, Button } from 'antd';
2 | import React, { useCallback } from 'react';
3 | import { useSelector, useDispatch } from 'react-redux';
4 |
5 | import { LOG_OUT_REQUEST } from '../reducers/user';
6 |
7 | const UserProfile = () => {
8 | const { me, logOutLoading } = useSelector((state) => state.user);
9 | const dispatch = useDispatch();
10 |
11 | const onLogout = useCallback(() => {
12 | dispatch({
13 | type: LOG_OUT_REQUEST,
14 | });
15 | }, []);
16 |
17 | return (
18 | 짹짹
{me.Posts.length},
21 | 팔로잉
{me.Followings.length}
,
22 | 팔로워
{me.Followers.length}
,
23 | ]}
24 | >
25 | {me.nickname[0]}}
27 | title={me.nickname}
28 | />
29 |
30 |
31 | );
32 | };
33 |
34 | export default UserProfile;
35 |
--------------------------------------------------------------------------------
/ch4/front/hooks/useInput.js:
--------------------------------------------------------------------------------
1 | import { useState, useCallback } from 'react';
2 |
3 | export default (initValue = null) => {
4 | const [value, setter] = useState(initValue);
5 | const handler = useCallback((e) => {
6 | setter(e.target.value);
7 | }, []);
8 | return [value, handler, setter];
9 | };
10 |
--------------------------------------------------------------------------------
/ch4/front/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-nodebird-front",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "dev": "next -p 3060",
8 | "build": "next build",
9 | "start": "next start"
10 | },
11 | "author": "ZeroCho",
12 | "license": "MIT",
13 | "dependencies": {
14 | "@ant-design/icons": "^4.2.1",
15 | "antd": "^4.3.0",
16 | "axios": "^1.3.4",
17 | "faker": "^4.1.0",
18 | "immer": "^9.0.19",
19 | "next": "^12.3.4",
20 | "next-redux-wrapper": "^6.0.1",
21 | "prop-types": "^15.7.2",
22 | "react": "^17.0.2",
23 | "react-dom": "^17.0.2",
24 | "react-redux": "^8.0.5",
25 | "react-slick": "^0.28.1",
26 | "redux": "^4.0.5",
27 | "redux-devtools-extension": "^2.13.8",
28 | "redux-saga": "^1.1.3",
29 | "shortid": "^2.2.15",
30 | "styled-components": "^5.1.1"
31 | },
32 | "devDependencies": {
33 | "babel-eslint": "^10.1.0",
34 | "eslint": "^7.1.0",
35 | "eslint-config-airbnb": "^18.1.0",
36 | "eslint-plugin-import": "^2.20.2",
37 | "eslint-plugin-jsx-a11y": "^6.2.3",
38 | "eslint-plugin-react": "^7.20.0",
39 | "eslint-plugin-react-hooks": "^4.0.4",
40 | "nodemon": "^2.0.4",
41 | "webpack": "^5.65.0"
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/ch4/front/pages/_app.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Head from 'next/head';
3 | import PropTypes from 'prop-types';
4 | import 'antd/dist/antd.css';
5 |
6 | import wrapper from '../store/configureStore';
7 |
8 | const NodeBird = ({ Component }) => (
9 | <>
10 |
11 | NodeBird
12 |
13 |
14 | >
15 | );
16 |
17 | NodeBird.propTypes = {
18 | Component: PropTypes.elementType.isRequired,
19 | };
20 |
21 | export function reportWebVitals(metric) {
22 | console.log(metric);
23 | }
24 |
25 | export default wrapper.withRedux(NodeBird);
26 |
--------------------------------------------------------------------------------
/ch4/front/pages/profile.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import Router from 'next/router';
3 | import { useSelector } from 'react-redux';
4 | import Head from 'next/head';
5 |
6 | import NicknameEditForm from '../components/NicknameEditForm';
7 | import AppLayout from '../components/AppLayout';
8 | import FollowList from '../components/FollowList';
9 |
10 | const Profile = () => {
11 | const { me } = useSelector((state) => state.user);
12 | useEffect(() => {
13 | if (!(me && me.id)) {
14 | Router.push('/');
15 | }
16 | }, [me && me.id]);
17 | if (!me) {
18 | return null;
19 | }
20 | return (
21 |
22 |
23 | 내 프로필 | NodeBird
24 |
25 |
26 |
30 |
34 |
35 | );
36 | };
37 |
38 | export default Profile;
39 |
--------------------------------------------------------------------------------
/ch4/front/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { HYDRATE } from 'next-redux-wrapper';
2 | import { combineReducers } from 'redux';
3 |
4 | import user from './user';
5 | import post from './post';
6 |
7 | // (이전상태, 액션) => 다음상태
8 | const rootReducer = combineReducers({
9 | index: (state = {}, action) => {
10 | switch (action.type) {
11 | case HYDRATE:
12 | console.log('HYDRATE', action);
13 | return { ...state, ...action.payload };
14 | default:
15 | return state;
16 | }
17 | },
18 | user,
19 | post,
20 | });
21 |
22 | export default rootReducer;
23 |
--------------------------------------------------------------------------------
/ch4/front/sagas/index.js:
--------------------------------------------------------------------------------
1 | import { all, fork } from 'redux-saga/effects';
2 |
3 | import postSaga from './post';
4 | import userSaga from './user';
5 |
6 | export default function* rootSaga() {
7 | yield all([
8 | fork(postSaga),
9 | fork(userSaga),
10 | ]);
11 | }
12 |
--------------------------------------------------------------------------------
/ch4/front/store/configureStore.js:
--------------------------------------------------------------------------------
1 | import { applyMiddleware, createStore, compose } from 'redux';
2 | import createSagaMiddleware from 'redux-saga';
3 | import { createWrapper } from 'next-redux-wrapper';
4 | import { composeWithDevTools } from 'redux-devtools-extension';
5 |
6 | import reducer from '../reducers';
7 | import rootSaga from '../sagas';
8 |
9 | const configureStore = (context) => {
10 | console.log(context);
11 | const sagaMiddleware = createSagaMiddleware();
12 | const middlewares = [sagaMiddleware];
13 | const enhancer = process.env.NODE_ENV === 'production'
14 | ? compose(applyMiddleware(...middlewares))
15 | : composeWithDevTools(
16 | applyMiddleware(...middlewares),
17 | );
18 | const store = createStore(reducer, enhancer);
19 | store.sagaTask = sagaMiddleware.run(rootSaga);
20 | return store;
21 | };
22 |
23 | const wrapper = createWrapper(configureStore, { debug: process.env.NODE_ENV === 'development' });
24 |
25 | export default wrapper;
26 |
--------------------------------------------------------------------------------
/ch4/front/util/produce.js:
--------------------------------------------------------------------------------
1 | import { enableES5, produce } from 'immer';
2 |
3 | export default (...args) => {
4 | enableES5();
5 | return produce(...args);
6 | };
7 |
--------------------------------------------------------------------------------
/ch5/back/config/config.js:
--------------------------------------------------------------------------------
1 | const dotenv = require('dotenv');
2 |
3 | dotenv.config();
4 |
5 | module.exports = {
6 | development: {
7 | username: 'root',
8 | password: process.env.DB_PASSWORD,
9 | database: 'react-nodebird',
10 | host: '127.0.0.1',
11 | dialect: 'mysql',
12 | },
13 | test: {
14 | username: 'root',
15 | password: process.env.DB_PASSWORD,
16 | database: 'react-nodebird',
17 | host: '127.0.0.1',
18 | dialect: 'mysql',
19 | },
20 | production: {
21 | username: 'root',
22 | password: process.env.DB_PASSWORD,
23 | database: 'react-nodebird',
24 | host: '127.0.0.1',
25 | dialect: 'mysql',
26 | },
27 | };
28 |
--------------------------------------------------------------------------------
/ch5/back/models/comment.js:
--------------------------------------------------------------------------------
1 | const DataTypes = require('sequelize');
2 | const { Model } = DataTypes;
3 |
4 | module.exports = class Comment extends Model {
5 | static init(sequelize) {
6 | return super.init({
7 | // id가 기본적으로 들어있다.
8 | content: {
9 | type: DataTypes.TEXT,
10 | allowNull: false,
11 | },
12 | // UserId: 1
13 | // PostId: 3
14 | }, {
15 | modelName: 'Comment',
16 | tableName: 'comments',
17 | charset: 'utf8mb4',
18 | collate: 'utf8mb4_general_ci', // 이모티콘 저장
19 | sequelize,
20 | });
21 | }
22 | static associate(db) {
23 | db.Comment.belongsTo(db.User);
24 | db.Comment.belongsTo(db.Post);
25 | }
26 | };
27 |
--------------------------------------------------------------------------------
/ch5/back/models/hashtag.js:
--------------------------------------------------------------------------------
1 | const DataTypes = require('sequelize');
2 | const { Model } = DataTypes;
3 |
4 | module.exports = class Hashtag extends Model {
5 | static init(sequelize) {
6 | return super.init({
7 | // id가 기본적으로 들어있다.
8 | name: {
9 | type: DataTypes.STRING(20),
10 | allowNull: false,
11 | },
12 | }, {
13 | modelName: 'Hashtag',
14 | tableName: 'hashtags',
15 | charset: 'utf8mb4',
16 | collate: 'utf8mb4_general_ci', // 이모티콘 저장
17 | sequelize,
18 | });
19 | }
20 | static associate(db) {
21 | db.Hashtag.belongsToMany(db.Post, { through: 'PostHashtag' });
22 | }
23 | };
24 |
--------------------------------------------------------------------------------
/ch5/back/models/image.js:
--------------------------------------------------------------------------------
1 | const DataTypes = require('sequelize');
2 | const { Model } = DataTypes;
3 |
4 | module.exports = class Image extends Model {
5 | static init(sequelize) {
6 | return super.init({
7 | // id가 기본적으로 들어있다.
8 | src: {
9 | type: DataTypes.STRING(200),
10 | allowNull: false,
11 | },
12 | }, {
13 | modelName: 'Image',
14 | tableName: 'images',
15 | charset: 'utf8',
16 | collate: 'utf8_general_ci',
17 | sequelize,
18 | });
19 | }
20 | static associate(db) {
21 | db.Image.belongsTo(db.Post);
22 | }
23 | };
24 |
--------------------------------------------------------------------------------
/ch5/back/models/index.js:
--------------------------------------------------------------------------------
1 | const Sequelize = require('sequelize');
2 | const comment = require('./comment');
3 | const hashtag = require('./hashtag');
4 | const image = require('./image');
5 | const post = require('./post');
6 | const user = require('./user');
7 |
8 | const env = process.env.NODE_ENV || 'development';
9 | const config = require('../config/config')[env];
10 | const db = {};
11 |
12 | const sequelize = new Sequelize(config.database, config.username, config.password, config);
13 |
14 | db.Comment = comment;
15 | db.Hashtag = hashtag;
16 | db.Image = image;
17 | db.Post = post;
18 | db.User = user;
19 |
20 | Object.keys(db).forEach(modelName => {
21 | db[modelName].init(sequelize);
22 | });
23 |
24 | Object.keys(db).forEach(modelName => {
25 | if (db[modelName].associate) {
26 | db[modelName].associate(db);
27 | }
28 | });
29 |
30 | db.sequelize = sequelize;
31 | db.Sequelize = Sequelize;
32 |
33 | module.exports = db;
34 |
--------------------------------------------------------------------------------
/ch5/back/models/post.js:
--------------------------------------------------------------------------------
1 | const DataTypes = require('sequelize');
2 | const { Model } = DataTypes;
3 |
4 | module.exports = class Post extends Model {
5 | static init(sequelize) {
6 | return super.init({
7 | // id가 기본적으로 들어있다.
8 | content: {
9 | type: DataTypes.TEXT,
10 | allowNull: false,
11 | },
12 | // RetweetId
13 | }, {
14 | modelName: 'Post',
15 | tableName: 'posts',
16 | charset: 'utf8mb4',
17 | collate: 'utf8mb4_general_ci', // 이모티콘 저장
18 | sequelize,
19 | });
20 | }
21 | static associate(db) {
22 | db.Post.belongsTo(db.User); // post.addUser, post.getUser, post.setUser
23 | db.Post.belongsToMany(db.Hashtag, { through: 'PostHashtag' }); // post.addHashtags
24 | db.Post.hasMany(db.Comment); // post.addComments, post.getComments
25 | db.Post.hasMany(db.Image); // post.addImages, post.getImages
26 | db.Post.belongsToMany(db.User, { through: 'Like', as: 'Likers' }) // post.addLikers, post.removeLikers
27 | db.Post.belongsTo(db.Post, { as: 'Retweet' }); // post.addRetweet
28 | }
29 | };
30 |
--------------------------------------------------------------------------------
/ch5/back/models/user.js:
--------------------------------------------------------------------------------
1 | const DataTypes = require('sequelize');
2 | const { Model } = DataTypes;
3 |
4 | module.exports = class User extends Model {
5 | static init(sequelize) {
6 | return super.init({
7 | // id가 기본적으로 들어있다.
8 | email: {
9 | type: DataTypes.STRING(30), // STRING, TEXT, BOOLEAN, INTEGER, FLOAT, DATETIME
10 | allowNull: false, // 필수
11 | unique: true, // 고유한 값
12 | },
13 | nickname: {
14 | type: DataTypes.STRING(30),
15 | allowNull: false, // 필수
16 | },
17 | password: {
18 | type: DataTypes.STRING(100),
19 | allowNull: false, // 필수
20 | },
21 | }, {
22 | modelName: 'User',
23 | tableName: 'users',
24 | charset: 'utf8',
25 | collate: 'utf8_general_ci', // 한글 저장
26 | sequelize,
27 | });
28 | }
29 | static associate(db) {
30 | db.User.hasMany(db.Post);
31 | db.User.hasMany(db.Comment);
32 | db.User.belongsToMany(db.Post, { through: 'Like', as: 'Liked' })
33 | db.User.belongsToMany(db.User, { through: 'Follow', as: 'Followers', foreignKey: 'FollowingId' });
34 | db.User.belongsToMany(db.User, { through: 'Follow', as: 'Followings', foreignKey: 'FollowerId' });
35 | }
36 | };
37 |
--------------------------------------------------------------------------------
/ch5/back/nodemon.json:
--------------------------------------------------------------------------------
1 | {
2 | "watch": [
3 | "index.js",
4 | "routes",
5 | "config",
6 | "passport",
7 | "models",
8 | "nodemon.json"
9 | ],
10 | "exec": "node app.js",
11 | "ext": "js json"
12 | }
13 |
--------------------------------------------------------------------------------
/ch5/back/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-nodebird-server",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "dev": "nodemon"
8 | },
9 | "author": "ZeroCho",
10 | "license": "MIT",
11 | "dependencies": {
12 | "axios": "^0.19.2",
13 | "bcrypt": "^5.0.0",
14 | "cookie-parser": "^1.4.5",
15 | "cors": "^2.8.5",
16 | "dotenv": "^8.2.0",
17 | "express": "^4.17.1",
18 | "express-session": "^1.17.1",
19 | "helmet": "^3.22.1",
20 | "hpp": "^0.2.3",
21 | "morgan": "^1.10.0",
22 | "multer": "^1.4.2",
23 | "mysql2": "^2.1.0",
24 | "passport": "^0.4.1",
25 | "passport-local": "^1.0.0",
26 | "sequelize": "^6.29.0",
27 | "sequelize-cli": "^5.5.1"
28 | },
29 | "devDependencies": {
30 | "eslint": "^7.2.0",
31 | "eslint-config-airbnb": "^18.1.0",
32 | "eslint-plugin-jsx-a11y": "^6.2.3",
33 | "nodemon": "^2.0.4"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/ch5/back/passport/index.js:
--------------------------------------------------------------------------------
1 | const passport = require('passport');
2 | const local = require('./local');
3 | const { User } = require('../models');
4 |
5 | module.exports = () => {
6 | passport.serializeUser((user, done) => { // 서버쪽에 [{ id: 1, cookie: 'clhxy' }]
7 | done(null, user.id);
8 | });
9 |
10 | passport.deserializeUser(async (id, done) => {
11 | try {
12 | const user = await User.findOne({ where: { id }});
13 | done(null, user); // req.user
14 | } catch (error) {
15 | console.error(error);
16 | done(error);
17 | }
18 | });
19 |
20 | local();
21 | };
22 |
23 | // 프론트에서 서버로는 cookie만 보내요(clhxy)
24 | // 서버가 쿠키파서, 익스프레스 세션으로 쿠키 검사 후 id: 1 발견
25 | // id: 1이 deserializeUser에 들어감
26 | // req.user로 사용자 정보가 들어감
27 |
28 | // 요청 보낼때마다 deserializeUser가 실행됨(db 요청 1번씩 실행)
29 | // 실무에서는 deserializeUser 결과물 캐싱
30 |
--------------------------------------------------------------------------------
/ch5/back/passport/local.js:
--------------------------------------------------------------------------------
1 | const passport = require('passport');
2 | const { Strategy: LocalStrategy } = require('passport-local');
3 | const bcrypt = require('bcrypt');
4 | const { User } = require('../models');
5 |
6 | module.exports = () => {
7 | passport.use(new LocalStrategy({
8 | usernameField: 'email',
9 | passwordField: 'password',
10 | }, async (email, password, done) => {
11 | try {
12 | const user = await User.findOne({
13 | where: { email }
14 | });
15 | if (!user) {
16 | return done(null, false, { reason: '존재하지 않는 이메일입니다!' });
17 | }
18 | const result = await bcrypt.compare(password, user.password);
19 | if (result) {
20 | return done(null, user);
21 | }
22 | return done(null, false, { reason: '비밀번호가 틀렸습니다.' });
23 | } catch (error) {
24 | console.error(error);
25 | return done(error);
26 | }
27 | }));
28 | };
29 |
--------------------------------------------------------------------------------
/ch5/back/routes/middlewares.js:
--------------------------------------------------------------------------------
1 | exports.isLoggedIn = (req, res, next) => {
2 | if (req.isAuthenticated()) {
3 | next();
4 | } else {
5 | res.status(401).send('로그인이 필요합니다.');
6 | }
7 | };
8 |
9 | exports.isNotLoggedIn = (req, res, next) => {
10 | if (!req.isAuthenticated()) {
11 | next();
12 | } else {
13 | res.status(401).send('로그인하지 않은 사용자만 접근 가능합니다.');
14 | }
15 | };
16 |
--------------------------------------------------------------------------------
/ch5/front/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "babel-eslint",
3 | "parserOptions": {
4 | "ecmaVersion": 2020,
5 | "sourceType": "module",
6 | "ecmaFeatures": {
7 | "jsx": true
8 | }
9 | },
10 | "env": {
11 | "browser": true,
12 | "node": true,
13 | "es6": true
14 | },
15 | "extends": [
16 | "airbnb"
17 | ],
18 | "plugins": [
19 | "import",
20 | "react-hooks"
21 | ],
22 | "rules": {
23 | "jsx-a11y/label-has-associated-control": "off",
24 | "jsx-a11y/anchor-is-valid": "off",
25 | "no-console": "off",
26 | "no-underscore-dangle": "off",
27 | "react/forbid-prop-types": "off",
28 | "react/jsx-filename-extension": "off",
29 | "react/jsx-one-expression-per-line": "off",
30 | "object-curly-newline": "off",
31 | "linebreak-style": "off",
32 | "no-param-reassign": "off"
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/ch5/front/components/FollowButton.js:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from 'react';
2 | import { Button } from 'antd';
3 | import PropTypes from 'prop-types';
4 | import { useSelector, useDispatch } from 'react-redux';
5 | import { FOLLOW_REQUEST, UNFOLLOW_REQUEST } from '../reducers/user';
6 |
7 | const FollowButton = ({ post }) => {
8 | const dispatch = useDispatch();
9 | const { me, followLoading, unfollowLoading } = useSelector((state) => state.user);
10 | const isFollowing = me?.Followings.find((v) => v.id === post.User.id);
11 | const onClickButton = useCallback(() => {
12 | if (isFollowing) {
13 | dispatch({
14 | type: UNFOLLOW_REQUEST,
15 | data: post.User.id,
16 | });
17 | } else {
18 | dispatch({
19 | type: FOLLOW_REQUEST,
20 | data: post.User.id,
21 | });
22 | }
23 | }, [isFollowing]);
24 |
25 | if (post.User.id === me.id) {
26 | return null;
27 | }
28 | return (
29 |
32 | );
33 | };
34 |
35 | FollowButton.propTypes = {
36 | post: PropTypes.object.isRequired,
37 | };
38 |
39 | export default FollowButton;
40 |
--------------------------------------------------------------------------------
/ch5/front/components/NicknameEditForm.js:
--------------------------------------------------------------------------------
1 | import { Form, Input } from 'antd';
2 | import React, { useCallback } from 'react';
3 | import { useSelector, useDispatch } from 'react-redux';
4 |
5 | import useInput from '../hooks/useInput';
6 | import { CHANGE_NICKNAME_REQUEST } from '../reducers/user';
7 |
8 | const NicknameEditForm = () => {
9 | const { me } = useSelector((state) => state.user);
10 | const [nickname, onChangeNickname] = useInput(me?.nickname || '');
11 | const dispatch = useDispatch();
12 |
13 | const onSubmit = useCallback(() => {
14 | dispatch({
15 | type: CHANGE_NICKNAME_REQUEST,
16 | data: nickname,
17 | });
18 | }, [nickname]);
19 |
20 | return (
21 |
30 | );
31 | };
32 |
33 | export default NicknameEditForm;
34 |
--------------------------------------------------------------------------------
/ch5/front/components/PostCardContent.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Link from 'next/link';
3 | import PropTypes from 'prop-types';
4 |
5 | const PostCardContent = ({ postData }) => ( // 첫 번째 게시글 #해시태그 #해시태그
6 |
7 | {postData.split(/(#[^\s#]+)/g).map((v, i) => {
8 | if (v.match(/(#[^\s#]+)/)) {
9 | return
{v}
10 | }
11 | return v;
12 | })}
13 |
14 | );
15 |
16 | PostCardContent.propTypes = { postData: PropTypes.string.isRequired };
17 |
18 | export default PostCardContent;
19 |
--------------------------------------------------------------------------------
/ch5/front/components/UserProfile.js:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from 'react';
2 | import { useDispatch, useSelector } from 'react-redux';
3 | import { Card, Avatar, Button } from 'antd';
4 |
5 | import { logoutRequestAction } from '../reducers/user';
6 |
7 | const UserProfile = () => {
8 | const dispatch = useDispatch();
9 | const { me, logOutLoading } = useSelector((state) => state.user);
10 |
11 | const onLogOut = useCallback(() => {
12 | dispatch(logoutRequestAction());
13 | }, []);
14 |
15 | return (
16 | 짹짹
{me.Posts.length},
19 | 팔로잉
{me.Followings.length}
,
20 | 팔로워
{me.Followers.length}
,
21 | ]}
22 | >
23 | {me.nickname[0]}}
25 | title={me.nickname}
26 | />
27 |
28 |
29 | );
30 | };
31 |
32 | export default UserProfile;
33 |
--------------------------------------------------------------------------------
/ch5/front/hooks/useInput.js:
--------------------------------------------------------------------------------
1 | import { useState, useCallback } from 'react';
2 |
3 | export default (initValue = null) => {
4 | const [value, setter] = useState(initValue);
5 | const handler = useCallback((e) => {
6 | setter(e.target.value);
7 | }, []);
8 | return [value, handler, setter];
9 | };
10 |
--------------------------------------------------------------------------------
/ch5/front/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-nodebird-front",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "dev": "next -p 3060",
8 | "build": "next build",
9 | "start": "next start"
10 | },
11 | "author": "ZeroCho",
12 | "license": "MIT",
13 | "dependencies": {
14 | "@ant-design/icons": "^4.2.1",
15 | "antd": "^4.3.0",
16 | "axios": "^1.3.4",
17 | "faker": "^4.1.0",
18 | "immer": "^9.0.19",
19 | "next": "^12.3.4",
20 | "next-redux-saga": "^4.1.2",
21 | "next-redux-wrapper": "^6.0.1",
22 | "prop-types": "^15.7.2",
23 | "react": "^17.0.2",
24 | "react-dom": "^17.0.2",
25 | "react-redux": "^8.0.5",
26 | "react-slick": "^0.28.1",
27 | "redux": "^4.0.5",
28 | "redux-devtools-extension": "^2.13.8",
29 | "redux-saga": "^1.1.3",
30 | "shortid": "^2.2.15",
31 | "styled-components": "^5.1.1"
32 | },
33 | "devDependencies": {
34 | "babel-eslint": "^10.1.0",
35 | "eslint": "^7.1.0",
36 | "eslint-config-airbnb": "^18.1.0",
37 | "eslint-plugin-import": "^2.20.2",
38 | "eslint-plugin-jsx-a11y": "^6.2.3",
39 | "eslint-plugin-react": "^7.20.0",
40 | "eslint-plugin-react-hooks": "^4.0.4",
41 | "nodemon": "^2.0.4",
42 | "webpack": "^5.65.0"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/ch5/front/pages/_app.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import Head from 'next/head';
4 | import 'antd/dist/antd.css';
5 |
6 | import wrapper from '../store/configureStore';
7 |
8 | const NodeBird = ({ Component }) => (
9 | <>
10 |
11 |
12 | NodeBird
13 |
14 |
15 | >
16 | );
17 |
18 | NodeBird.propTypes = {
19 | Component: PropTypes.elementType.isRequired,
20 | };
21 |
22 | export default wrapper.withRedux(NodeBird);
23 |
--------------------------------------------------------------------------------
/ch5/front/pages/profile.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import Head from 'next/head';
3 | import { useSelector, useDispatch } from 'react-redux';
4 | import Router from 'next/router';
5 |
6 | import AppLayout from '../components/AppLayout';
7 | import NicknameEditForm from '../components/NicknameEditForm';
8 | import FollowList from '../components/FollowList';
9 | import { LOAD_FOLLOWERS_REQUEST, LOAD_FOLLOWINGS_REQUEST } from '../reducers/user';
10 |
11 | const Profile = () => {
12 | const dispatch = useDispatch();
13 |
14 | const { me } = useSelector((state) => state.user);
15 |
16 | useEffect(() => {
17 | dispatch({
18 | type: LOAD_FOLLOWERS_REQUEST,
19 | });
20 | dispatch({
21 | type: LOAD_FOLLOWINGS_REQUEST,
22 | });
23 | }, []);
24 |
25 | useEffect(() => {
26 | if (!(me && me.id)) {
27 | Router.push('/');
28 | }
29 | }, [me && me.id]);
30 |
31 | if (!me) {
32 | return null;
33 | }
34 | return (
35 | <>
36 |
37 | 내 프로필 | NodeBird
38 |
39 |
40 |
41 |
42 |
43 |
44 | >
45 | );
46 | };
47 |
48 | export default Profile;
49 |
--------------------------------------------------------------------------------
/ch5/front/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { HYDRATE } from 'next-redux-wrapper';
2 | import { combineReducers } from 'redux';
3 |
4 | import user from './user';
5 | import post from './post';
6 |
7 | // (이전상태, 액션) => 다음상태
8 | const rootReducer = combineReducers({
9 | index: (state = {}, action) => {
10 | switch (action.type) {
11 | case HYDRATE:
12 | console.log('HYDRATE', action);
13 | return { ...state, ...action.payload };
14 | default:
15 | return state;
16 | }
17 | },
18 | user,
19 | post,
20 | });
21 |
22 | export default rootReducer;
23 |
--------------------------------------------------------------------------------
/ch5/front/sagas/index.js:
--------------------------------------------------------------------------------
1 | import { all, fork } from 'redux-saga/effects';
2 | import axios from 'axios';
3 |
4 | import postSaga from './post';
5 | import userSaga from './user';
6 |
7 | axios.defaults.baseURL = 'http://localhost:3065';
8 | axios.defaults.withCredentials = true;
9 |
10 | export default function* rootSaga() {
11 | yield all([
12 | fork(postSaga),
13 | fork(userSaga),
14 | ]);
15 | }
16 |
--------------------------------------------------------------------------------
/ch5/front/store/configureStore.js:
--------------------------------------------------------------------------------
1 | import { applyMiddleware, createStore, compose } from 'redux';
2 | import createSagaMiddleware from 'redux-saga';
3 | import { createWrapper } from 'next-redux-wrapper';
4 | import { composeWithDevTools } from 'redux-devtools-extension';
5 |
6 | import reducer from '../reducers';
7 | import rootSaga from '../sagas';
8 |
9 | const configureStore = (context) => {
10 | console.log(context);
11 | const sagaMiddleware = createSagaMiddleware();
12 | const middlewares = [sagaMiddleware];
13 | const enhancer = process.env.NODE_ENV === 'production'
14 | ? compose(applyMiddleware(...middlewares))
15 | : composeWithDevTools(
16 | applyMiddleware(...middlewares),
17 | );
18 | const store = createStore(reducer, enhancer);
19 | store.sagaTask = sagaMiddleware.run(rootSaga);
20 | return store;
21 | };
22 |
23 | const wrapper = createWrapper(configureStore, { debug: process.env.NODE_ENV === 'development' });
24 |
25 | export default wrapper;
26 |
--------------------------------------------------------------------------------
/ch5/front/util/produce.js:
--------------------------------------------------------------------------------
1 | import { enableES5, produce } from 'immer';
2 |
3 | export default (...args) => {
4 | enableES5();
5 | return produce(...args);
6 | };
7 |
--------------------------------------------------------------------------------
/ch6/back/config/config.js:
--------------------------------------------------------------------------------
1 | const dotenv = require('dotenv');
2 |
3 | dotenv.config();
4 |
5 | module.exports = {
6 | development: {
7 | username: 'root',
8 | password: process.env.DB_PASSWORD,
9 | database: 'react-nodebird',
10 | host: '127.0.0.1',
11 | dialect: 'mysql',
12 | },
13 | test: {
14 | username: 'root',
15 | password: process.env.DB_PASSWORD,
16 | database: 'react-nodebird',
17 | host: '127.0.0.1',
18 | dialect: 'mysql',
19 | },
20 | production: {
21 | username: 'root',
22 | password: process.env.DB_PASSWORD,
23 | database: 'react-nodebird',
24 | host: '127.0.0.1',
25 | dialect: 'mysql',
26 | },
27 | };
28 |
--------------------------------------------------------------------------------
/ch6/back/models/comment.js:
--------------------------------------------------------------------------------
1 | const DataTypes = require('sequelize');
2 | const { Model } = DataTypes;
3 |
4 | module.exports = class Comment extends Model {
5 | static init(sequelize) {
6 | return super.init({
7 | // id가 기본적으로 들어있다.
8 | content: {
9 | type: DataTypes.TEXT,
10 | allowNull: false,
11 | },
12 | // UserId: 1
13 | // PostId: 3
14 | }, {
15 | modelName: 'Comment',
16 | tableName: 'comments',
17 | charset: 'utf8mb4',
18 | collate: 'utf8mb4_general_ci', // 이모티콘 저장
19 | sequelize,
20 | });
21 | }
22 | static associate(db) {
23 | db.Comment.belongsTo(db.User);
24 | db.Comment.belongsTo(db.Post);
25 | }
26 | };
27 |
--------------------------------------------------------------------------------
/ch6/back/models/hashtag.js:
--------------------------------------------------------------------------------
1 | const DataTypes = require('sequelize');
2 | const { Model } = DataTypes;
3 |
4 | module.exports = class Hashtag extends Model {
5 | static init(sequelize) {
6 | return super.init({
7 | // id가 기본적으로 들어있다.
8 | name: {
9 | type: DataTypes.STRING(20),
10 | allowNull: false,
11 | },
12 | }, {
13 | modelName: 'Hashtag',
14 | tableName: 'hashtags',
15 | charset: 'utf8mb4',
16 | collate: 'utf8mb4_general_ci', // 이모티콘 저장
17 | sequelize,
18 | });
19 | }
20 | static associate(db) {
21 | db.Hashtag.belongsToMany(db.Post, { through: 'PostHashtag' });
22 | }
23 | };
24 |
--------------------------------------------------------------------------------
/ch6/back/models/image.js:
--------------------------------------------------------------------------------
1 | const DataTypes = require('sequelize');
2 | const { Model } = DataTypes;
3 |
4 | module.exports = class Image extends Model {
5 | static init(sequelize) {
6 | return super.init({
7 | // id가 기본적으로 들어있다.
8 | src: {
9 | type: DataTypes.STRING(200),
10 | allowNull: false,
11 | },
12 | }, {
13 | modelName: 'Image',
14 | tableName: 'images',
15 | charset: 'utf8',
16 | collate: 'utf8_general_ci',
17 | sequelize,
18 | });
19 | }
20 | static associate(db) {
21 | db.Image.belongsTo(db.Post);
22 | }
23 | };
24 |
--------------------------------------------------------------------------------
/ch6/back/models/index.js:
--------------------------------------------------------------------------------
1 | const Sequelize = require('sequelize');
2 | const comment = require('./comment');
3 | const hashtag = require('./hashtag');
4 | const image = require('./image');
5 | const post = require('./post');
6 | const user = require('./user');
7 |
8 | const env = process.env.NODE_ENV || 'development';
9 | const config = require('../config/config')[env];
10 | const db = {};
11 |
12 | const sequelize = new Sequelize(config.database, config.username, config.password, config);
13 |
14 | db.Comment = comment;
15 | db.Hashtag = hashtag;
16 | db.Image = image;
17 | db.Post = post;
18 | db.User = user;
19 |
20 | Object.keys(db).forEach(modelName => {
21 | db[modelName].init(sequelize);
22 | });
23 |
24 | Object.keys(db).forEach(modelName => {
25 | if (db[modelName].associate) {
26 | db[modelName].associate(db);
27 | }
28 | });
29 |
30 | db.sequelize = sequelize;
31 | db.Sequelize = Sequelize;
32 |
33 | module.exports = db;
34 |
--------------------------------------------------------------------------------
/ch6/back/models/post.js:
--------------------------------------------------------------------------------
1 | const DataTypes = require('sequelize');
2 | const { Model } = DataTypes;
3 |
4 | module.exports = class Post extends Model {
5 | static init(sequelize) {
6 | return super.init({
7 | // id가 기본적으로 들어있다.
8 | content: {
9 | type: DataTypes.TEXT,
10 | allowNull: false,
11 | },
12 | // RetweetId
13 | }, {
14 | modelName: 'Post',
15 | tableName: 'posts',
16 | charset: 'utf8mb4',
17 | collate: 'utf8mb4_general_ci', // 이모티콘 저장
18 | sequelize,
19 | });
20 | }
21 | static associate(db) {
22 | db.Post.belongsTo(db.User); // post.addUser, post.getUser, post.setUser
23 | db.Post.belongsToMany(db.Hashtag, { through: 'PostHashtag' }); // post.addHashtags
24 | db.Post.hasMany(db.Comment); // post.addComments, post.getComments
25 | db.Post.hasMany(db.Image); // post.addImages, post.getImages
26 | db.Post.belongsToMany(db.User, { through: 'Like', as: 'Likers' }) // post.addLikers, post.removeLikers
27 | db.Post.belongsTo(db.Post, { as: 'Retweet' }); // post.addRetweet
28 | }
29 | };
30 |
--------------------------------------------------------------------------------
/ch6/back/models/user.js:
--------------------------------------------------------------------------------
1 | const DataTypes = require('sequelize');
2 | const { Model } = DataTypes;
3 |
4 | module.exports = class User extends Model {
5 | static init(sequelize) {
6 | return super.init({
7 | // id가 기본적으로 들어있다.
8 | email: {
9 | type: DataTypes.STRING(30), // STRING, TEXT, BOOLEAN, INTEGER, FLOAT, DATETIME
10 | allowNull: false, // 필수
11 | unique: true, // 고유한 값
12 | },
13 | nickname: {
14 | type: DataTypes.STRING(30),
15 | allowNull: false, // 필수
16 | },
17 | password: {
18 | type: DataTypes.STRING(100),
19 | allowNull: false, // 필수
20 | },
21 | }, {
22 | modelName: 'User',
23 | tableName: 'users',
24 | charset: 'utf8',
25 | collate: 'utf8_general_ci', // 한글 저장
26 | sequelize,
27 | });
28 | }
29 | static associate(db) {
30 | db.User.hasMany(db.Post);
31 | db.User.hasMany(db.Comment);
32 | db.User.belongsToMany(db.Post, { through: 'Like', as: 'Liked' })
33 | db.User.belongsToMany(db.User, { through: 'Follow', as: 'Followers', foreignKey: 'FollowingId' });
34 | db.User.belongsToMany(db.User, { through: 'Follow', as: 'Followings', foreignKey: 'FollowerId' });
35 | }
36 | };
37 |
--------------------------------------------------------------------------------
/ch6/back/nodemon.json:
--------------------------------------------------------------------------------
1 | {
2 | "watch": [
3 | "index.js",
4 | "routes",
5 | "config",
6 | "passport",
7 | "models",
8 | "nodemon.json"
9 | ],
10 | "exec": "node app.js",
11 | "ext": "js json"
12 | }
13 |
--------------------------------------------------------------------------------
/ch6/back/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-nodebird-server",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "dev": "nodemon"
8 | },
9 | "author": "ZeroCho",
10 | "license": "MIT",
11 | "dependencies": {
12 | "axios": "^0.19.2",
13 | "bcrypt": "^5.0.0",
14 | "cookie-parser": "^1.4.5",
15 | "cors": "^2.8.5",
16 | "dotenv": "^8.2.0",
17 | "express": "^4.17.1",
18 | "express-session": "^1.17.1",
19 | "helmet": "^3.23.2",
20 | "hpp": "^0.2.3",
21 | "morgan": "^1.10.0",
22 | "multer": "^1.4.2",
23 | "mysql2": "^2.1.0",
24 | "passport": "^0.4.1",
25 | "passport-local": "^1.0.0",
26 | "sequelize": "^6.1.0",
27 | "sequelize-cli": "^6.0.0"
28 | },
29 | "devDependencies": {
30 | "eslint": "^7.3.1",
31 | "eslint-config-airbnb": "^18.2.0",
32 | "eslint-plugin-jsx-a11y": "^6.3.1",
33 | "nodemon": "2.0.4"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/ch6/back/passport/index.js:
--------------------------------------------------------------------------------
1 | const passport = require('passport');
2 | const local = require('./local');
3 | const { User } = require('../models');
4 |
5 | module.exports = () => {
6 | passport.serializeUser((user, done) => { // 서버쪽에 [{ id: 1, cookie: 'clhxy' }]
7 | done(null, user.id);
8 | });
9 |
10 | passport.deserializeUser(async (id, done) => {
11 | try {
12 | const user = await User.findOne({ where: { id }});
13 | done(null, user); // req.user
14 | } catch (error) {
15 | console.error(error);
16 | done(error);
17 | }
18 | });
19 |
20 | local();
21 | };
22 |
23 | // 프론트에서 서버로는 cookie만 보내요(clhxy)
24 | // 서버가 쿠키파서, 익스프레스 세션으로 쿠키 검사 후 id: 1 발견
25 | // id: 1이 deserializeUser에 들어감
26 | // req.user로 사용자 정보가 들어감
27 |
28 | // 요청 보낼때마다 deserializeUser가 실행됨(db 요청 1번씩 실행)
29 | // 실무에서는 deserializeUser 결과물 캐싱
30 |
--------------------------------------------------------------------------------
/ch6/back/passport/local.js:
--------------------------------------------------------------------------------
1 | const passport = require('passport');
2 | const { Strategy: LocalStrategy } = require('passport-local');
3 | const bcrypt = require('bcrypt');
4 | const { User } = require('../models');
5 |
6 | module.exports = () => {
7 | passport.use(new LocalStrategy({
8 | usernameField: 'email',
9 | passwordField: 'password',
10 | }, async (email, password, done) => {
11 | try {
12 | const user = await User.findOne({
13 | where: { email }
14 | });
15 | if (!user) {
16 | return done(null, false, { reason: '존재하지 않는 이메일입니다!' });
17 | }
18 | const result = await bcrypt.compare(password, user.password);
19 | if (result) {
20 | return done(null, user);
21 | }
22 | return done(null, false, { reason: '비밀번호가 틀렸습니다.' });
23 | } catch (error) {
24 | console.error(error);
25 | return done(error);
26 | }
27 | }));
28 | };
29 |
--------------------------------------------------------------------------------
/ch6/back/routes/hashtag.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const { Op } = require('sequelize');
3 |
4 | const { User, Hashtag, Image, Post } = require('../models');
5 |
6 | const router = express.Router();
7 |
8 | router.get('/:tag', async (req, res, next) => {
9 | try {
10 | const where = {};
11 | if (parseInt(req.query.lastId, 10)) { // 초기 로딩이 아닐 때
12 | where.id = { [Op.lt]: parseInt(req.query.lastId, 10)}
13 | } // 21 20 19 18 17 16 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1
14 | const posts = await Post.findAll({
15 | where,
16 | limit: 10,
17 | include: [{
18 | model: Hashtag,
19 | where: { name: decodeURIComponent(req.params.tag) },
20 | }, {
21 | model: User,
22 | attributes: ['id', 'nickname'],
23 | }, {
24 | model: Image,
25 | }, {
26 | model: User,
27 | through: 'Like',
28 | as: 'Likers',
29 | attributes: ['id'],
30 | }, {
31 | model: Post,
32 | as: 'Retweet',
33 | include: [{
34 | model: User,
35 | attributes: ['id', 'nickname'],
36 | }, {
37 | model: Image,
38 | }],
39 | }],
40 | });
41 | res.json(posts);
42 | } catch (e) {
43 | console.error(e);
44 | next(e);
45 | }
46 | });
47 |
48 | module.exports = router;
49 |
--------------------------------------------------------------------------------
/ch6/back/routes/middlewares.js:
--------------------------------------------------------------------------------
1 | exports.isLoggedIn = (req, res, next) => {
2 | if (req.isAuthenticated()) {
3 | next();
4 | } else {
5 | res.status(401).send('로그인이 필요합니다.');
6 | }
7 | };
8 |
9 | exports.isNotLoggedIn = (req, res, next) => {
10 | if (!req.isAuthenticated()) {
11 | next();
12 | } else {
13 | res.status(401).send('로그인하지 않은 사용자만 접근 가능합니다.');
14 | }
15 | };
16 |
--------------------------------------------------------------------------------
/ch6/front/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "next/babel"
4 | ],
5 | "plugins": [
6 | [
7 | "babel-plugin-styled-components",
8 | {
9 | "ssr": true,
10 | "displayName": true
11 | }
12 | ]
13 | ]
14 | }
15 |
--------------------------------------------------------------------------------
/ch6/front/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "babel-eslint",
3 | "parserOptions": {
4 | "ecmaVersion": 2020,
5 | "sourceType": "module",
6 | "ecmaFeatures": {
7 | "jsx": true
8 | }
9 | },
10 | "env": {
11 | "browser": true,
12 | "node": true,
13 | "es6": true
14 | },
15 | "extends": [
16 | "airbnb"
17 | ],
18 | "plugins": [
19 | "import",
20 | "react-hooks"
21 | ],
22 | "rules": {
23 | "jsx-a11y/label-has-associated-control": "off",
24 | "jsx-a11y/anchor-is-valid": "off",
25 | "no-console": "off",
26 | "no-underscore-dangle": "off",
27 | "react/forbid-prop-types": "off",
28 | "react/jsx-filename-extension": "off",
29 | "object-curly-newline": "off",
30 | "linebreak-style": "off",
31 | "no-param-reassign": "off"
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/ch6/front/components/FollowButton.js:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from 'react';
2 | import { Button } from 'antd';
3 | import PropTypes from 'prop-types';
4 | import { useSelector, useDispatch } from 'react-redux';
5 | import { FOLLOW_REQUEST, UNFOLLOW_REQUEST } from '../reducers/user';
6 |
7 | const FollowButton = ({ post }) => {
8 | const dispatch = useDispatch();
9 | const { me, followLoading, unfollowLoading } = useSelector((state) => state.user);
10 | const isFollowing = me?.Followings.find((v) => v.id === post.User.id);
11 | const onClickButton = useCallback(() => {
12 | if (isFollowing) {
13 | dispatch({
14 | type: UNFOLLOW_REQUEST,
15 | data: post.User.id,
16 | });
17 | } else {
18 | dispatch({
19 | type: FOLLOW_REQUEST,
20 | data: post.User.id,
21 | });
22 | }
23 | }, [isFollowing]);
24 |
25 | if (post.User.id === me.id) {
26 | return null;
27 | }
28 | return (
29 |
32 | );
33 | };
34 |
35 | FollowButton.propTypes = {
36 | post: PropTypes.object.isRequired,
37 | };
38 |
39 | export default FollowButton;
40 |
--------------------------------------------------------------------------------
/ch6/front/components/NicknameEditForm.js:
--------------------------------------------------------------------------------
1 | import { Form, Input } from 'antd';
2 | import React, { useCallback } from 'react';
3 | import { useSelector, useDispatch } from 'react-redux';
4 |
5 | import useInput from '../hooks/useInput';
6 | import { CHANGE_NICKNAME_REQUEST } from '../reducers/user';
7 |
8 | const NicknameEditForm = () => {
9 | const { me } = useSelector((state) => state.user);
10 | const [nickname, onChangeNickname] = useInput(me?.nickname || '');
11 | const dispatch = useDispatch();
12 |
13 | const onSubmit = useCallback(() => {
14 | dispatch({
15 | type: CHANGE_NICKNAME_REQUEST,
16 | data: nickname,
17 | });
18 | }, [nickname]);
19 |
20 | return (
21 |
30 | );
31 | };
32 |
33 | export default NicknameEditForm;
34 |
--------------------------------------------------------------------------------
/ch6/front/components/PostCardContent.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Link from 'next/link';
3 | import PropTypes from 'prop-types';
4 |
5 | const PostCardContent = ({ postData }) => ( // 첫 번째 게시글 #해시태그 #해시태그
6 |
7 | {postData.split(/(#[^\s#]+)/g).map((v, i) => {
8 | if (v.match(/(#[^\s#]+)/)) {
9 | return
{v}
10 | }
11 | return v;
12 | })}
13 |
14 | );
15 |
16 | PostCardContent.propTypes = { postData: PropTypes.string.isRequired };
17 |
18 | export default PostCardContent;
19 |
--------------------------------------------------------------------------------
/ch6/front/config/config.js:
--------------------------------------------------------------------------------
1 | exports.backUrl = 'http://localhost:3065';
2 |
--------------------------------------------------------------------------------
/ch6/front/hooks/useInput.js:
--------------------------------------------------------------------------------
1 | import { useState, useCallback } from 'react';
2 |
3 | export default (initValue = null) => {
4 | const [value, setter] = useState(initValue);
5 | const handler = useCallback((e) => {
6 | setter(e.target.value);
7 | }, []);
8 | return [value, handler, setter];
9 | };
10 |
--------------------------------------------------------------------------------
/ch6/front/next.config.js:
--------------------------------------------------------------------------------
1 | const withBundleAnalyzer = require('@next/bundle-analyzer')({
2 | enabled: process.env.ANALYZE === 'true',
3 | });
4 |
5 | module.exports = withBundleAnalyzer({
6 | distDir: '.next',
7 | webpack(config, { webpack }) {
8 | const prod = process.env.NODE_ENV === 'production';
9 | const plugins = [
10 | ...config.plugins,
11 | new webpack.ContextReplacementPlugin(/moment[/\\]locale$/, /^\.\/ko$/),
12 | ];
13 | return {
14 | ...config,
15 | mode: prod ? 'production' : 'development',
16 | devtool: prod ? 'hidden-source-map' : 'eval',
17 | plugins,
18 | };
19 | },
20 | });
21 |
--------------------------------------------------------------------------------
/ch6/front/pages/_app.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Head from 'next/head';
3 | import PropTypes from 'prop-types';
4 | import 'antd/dist/antd.css';
5 |
6 | import wrapper from '../store/configureStore';
7 |
8 | const NodeBird = ({ Component }) => (
9 | <>
10 |
11 | NodeBird
12 |
13 |
14 |
15 | >
16 | );
17 |
18 | NodeBird.propTypes = {
19 | Component: PropTypes.elementType.isRequired,
20 | };
21 |
22 | export default wrapper.withRedux(NodeBird);
23 |
--------------------------------------------------------------------------------
/ch6/front/pages/_document.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Document, { Html, Head, Main, NextScript } from 'next/document';
3 | import { ServerStyleSheet } from 'styled-components';
4 |
5 | export default class MyDocument extends Document {
6 | static async getInitialProps(ctx) {
7 | const sheet = new ServerStyleSheet();
8 | const originalRenderPage = ctx.renderPage;
9 |
10 | try {
11 | ctx.renderPage = () => originalRenderPage({
12 | enhanceApp: App => props => sheet.collectStyles(),
13 | });
14 |
15 | const initialProps = await Document.getInitialProps(ctx);
16 | return {
17 | ...initialProps,
18 | styles: (
19 | <>
20 | {initialProps.styles}
21 | {sheet.getStyleElement()}
22 | >
23 | ),
24 | };
25 | } finally {
26 | sheet.seal();
27 | }
28 | }
29 |
30 | render() {
31 | return (
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | );
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/ch6/front/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ZeroCho/react-nodebird/54bc0260fa5b78c9b7a460ca8832b485cdd2ec82/ch6/front/public/favicon.ico
--------------------------------------------------------------------------------
/ch6/front/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { HYDRATE } from 'next-redux-wrapper';
2 | import { combineReducers } from 'redux';
3 |
4 | import user from './user';
5 | import post from './post';
6 |
7 | // (이전상태, 액션) => 다음상태
8 | const rootReducer = (state, action) => {
9 | switch (action.type) {
10 | case HYDRATE:
11 | console.log('HYDRATE', action);
12 | return action.payload;
13 | default: {
14 | const combinedReducer = combineReducers({
15 | user,
16 | post,
17 | });
18 | return combinedReducer(state, action);
19 | }
20 | }
21 | };
22 |
23 | export default rootReducer;
24 |
--------------------------------------------------------------------------------
/ch6/front/sagas/index.js:
--------------------------------------------------------------------------------
1 | import { all, fork } from 'redux-saga/effects';
2 | import axios from 'axios';
3 |
4 | import postSaga from './post';
5 | import userSaga from './user';
6 |
7 | axios.defaults.baseURL = 'http://localhost:3065';
8 | axios.defaults.withCredentials = true;
9 |
10 | export default function* rootSaga() {
11 | yield all([
12 | fork(postSaga),
13 | fork(userSaga),
14 | ]);
15 | }
16 |
--------------------------------------------------------------------------------
/ch6/front/store/configureStore.js:
--------------------------------------------------------------------------------
1 | import { applyMiddleware, createStore, compose } from 'redux';
2 | import createSagaMiddleware from 'redux-saga';
3 | import { createWrapper } from 'next-redux-wrapper';
4 | import { composeWithDevTools } from 'redux-devtools-extension';
5 |
6 | import reducer from '../reducers';
7 | import rootSaga from '../sagas';
8 |
9 | const configureStore = () => {
10 | const sagaMiddleware = createSagaMiddleware();
11 | const middlewares = [sagaMiddleware];
12 | const enhancer = process.env.NODE_ENV === 'production'
13 | ? compose(applyMiddleware(...middlewares))
14 | : composeWithDevTools(
15 | applyMiddleware(...middlewares),
16 | );
17 | const store = createStore(reducer, enhancer);
18 | store.sagaTask = sagaMiddleware.run(rootSaga);
19 | return store;
20 | };
21 |
22 | const wrapper = createWrapper(configureStore, { debug: process.env.NODE_ENV === 'development' });
23 |
24 | export default wrapper;
25 |
--------------------------------------------------------------------------------
/ch6/front/util/produce.js:
--------------------------------------------------------------------------------
1 | import { enableES5, produce } from 'immer';
2 |
3 | export default (...args) => {
4 | enableES5();
5 | return produce(...args);
6 | };
7 |
--------------------------------------------------------------------------------
/ch7/back/config/config.js:
--------------------------------------------------------------------------------
1 | const dotenv = require('dotenv');
2 |
3 | dotenv.config();
4 |
5 | module.exports = {
6 | development: {
7 | username: 'root',
8 | password: process.env.DB_PASSWORD,
9 | database: 'react-nodebird',
10 | host: '127.0.0.1',
11 | dialect: 'mysql',
12 | },
13 | test: {
14 | username: 'root',
15 | password: process.env.DB_PASSWORD,
16 | database: 'react-nodebird',
17 | host: '127.0.0.1',
18 | dialect: 'mysql',
19 | },
20 | production: {
21 | username: 'root',
22 | password: process.env.DB_PASSWORD,
23 | database: 'react-nodebird',
24 | host: '127.0.0.1',
25 | dialect: 'mysql',
26 | },
27 | };
28 |
--------------------------------------------------------------------------------
/ch7/back/models/comment.js:
--------------------------------------------------------------------------------
1 | const DataTypes = require('sequelize');
2 | const { Model } = DataTypes;
3 |
4 | module.exports = class Comment extends Model {
5 | static init(sequelize) {
6 | return super.init({
7 | // id가 기본적으로 들어있다.
8 | content: {
9 | type: DataTypes.TEXT,
10 | allowNull: false,
11 | },
12 | // UserId: 1
13 | // PostId: 3
14 | }, {
15 | modelName: 'Comment',
16 | tableName: 'comments',
17 | charset: 'utf8mb4',
18 | collate: 'utf8mb4_general_ci', // 이모티콘 저장
19 | sequelize,
20 | });
21 | }
22 |
23 | static associate(db) {
24 | db.Comment.belongsTo(db.User);
25 | db.Comment.belongsTo(db.Post);
26 | }
27 | };
28 |
--------------------------------------------------------------------------------
/ch7/back/models/hashtag.js:
--------------------------------------------------------------------------------
1 | const DataTypes = require('sequelize');
2 | const { Model } = DataTypes;
3 |
4 | module.exports = class Hashtag extends Model {
5 | static init(sequelize) {
6 | return super.init({
7 | // id가 기본적으로 들어있다.
8 | name: {
9 | type: DataTypes.STRING(20),
10 | allowNull: false,
11 | },
12 | }, {
13 | modelName: 'Hashtag',
14 | tableName: 'hashtags',
15 | charset: 'utf8mb4',
16 | collate: 'utf8mb4_general_ci', // 이모티콘 저장
17 | sequelize,
18 | });
19 | }
20 | static associate(db) {
21 | db.Hashtag.belongsToMany(db.Post, { through: 'PostHashtag' });
22 | }
23 | };
24 |
--------------------------------------------------------------------------------
/ch7/back/models/image.js:
--------------------------------------------------------------------------------
1 | const DataTypes = require('sequelize');
2 | const { Model } = DataTypes;
3 |
4 | module.exports = class Image extends Model {
5 | static init(sequelize) {
6 | return super.init({
7 | // id가 기본적으로 들어있다.
8 | src: {
9 | type: DataTypes.STRING(200),
10 | allowNull: false,
11 | },
12 | }, {
13 | modelName: 'Image',
14 | tableName: 'images',
15 | charset: 'utf8',
16 | collate: 'utf8_general_ci',
17 | sequelize,
18 | });
19 | }
20 | static associate(db) {
21 | db.Image.belongsTo(db.Post);
22 | }
23 | };
24 |
--------------------------------------------------------------------------------
/ch7/back/models/index.js:
--------------------------------------------------------------------------------
1 | const Sequelize = require('sequelize');
2 | const comment = require('./comment');
3 | const hashtag = require('./hashtag');
4 | const image = require('./image');
5 | const post = require('./post');
6 | const user = require('./user');
7 |
8 | const env = process.env.NODE_ENV || 'development';
9 | const config = require('../config/config')[env];
10 | const db = {};
11 |
12 | const sequelize = new Sequelize(config.database, config.username, config.password, config);
13 |
14 | db.Comment = comment;
15 | db.Hashtag = hashtag;
16 | db.Image = image;
17 | db.Post = post;
18 | db.User = user;
19 |
20 | Object.keys(db).forEach(modelName => {
21 | db[modelName].init(sequelize);
22 | })
23 |
24 | Object.keys(db).forEach(modelName => {
25 | if (db[modelName].associate) {
26 | db[modelName].associate(db);
27 | }
28 | });
29 |
30 | db.sequelize = sequelize;
31 | db.Sequelize = Sequelize;
32 |
33 | module.exports = db;
34 |
--------------------------------------------------------------------------------
/ch7/back/models/post.js:
--------------------------------------------------------------------------------
1 | const DataTypes = require('sequelize');
2 | const { Model } = DataTypes;
3 |
4 | module.exports = class Post extends Model {
5 | static init(sequelize) {
6 | return super.init({
7 | // id가 기본적으로 들어있다.
8 | content: {
9 | type: DataTypes.TEXT,
10 | allowNull: false,
11 | },
12 | // RetweetId
13 | }, {
14 | modelName: 'Post',
15 | tableName: 'posts',
16 | charset: 'utf8mb4',
17 | collate: 'utf8mb4_general_ci', // 이모티콘 저장
18 | sequelize,
19 | });
20 | }
21 | static associate(db) {
22 | db.Post.belongsTo(db.User); // post.addUser, post.getUser, post.setUser
23 | db.Post.belongsToMany(db.Hashtag, { through: 'PostHashtag' }); // post.addHashtags
24 | db.Post.hasMany(db.Comment); // post.addComments, post.getComments
25 | db.Post.hasMany(db.Image); // post.addImages, post.getImages
26 | db.Post.belongsToMany(db.User, { through: 'Like', as: 'Likers' }) // post.addLikers, post.removeLikers
27 | db.Post.belongsTo(db.Post, { as: 'Retweet' }); // post.addRetweet
28 | }
29 | };
30 |
--------------------------------------------------------------------------------
/ch7/back/models/user.js:
--------------------------------------------------------------------------------
1 | const DataTypes = require('sequelize');
2 | const { Model } = DataTypes;
3 |
4 | module.exports = class User extends Model {
5 | static init(sequelize) {
6 | return super.init({
7 | // id가 기본적으로 들어있다.
8 | email: {
9 | type: DataTypes.STRING(30), // STRING, TEXT, BOOLEAN, INTEGER, FLOAT, DATETIME
10 | allowNull: false, // 필수
11 | unique: true, // 고유한 값
12 | },
13 | nickname: {
14 | type: DataTypes.STRING(30),
15 | allowNull: false, // 필수
16 | },
17 | password: {
18 | type: DataTypes.STRING(100),
19 | allowNull: false, // 필수
20 | },
21 | }, {
22 | modelName: 'User',
23 | tableName: 'users',
24 | charset: 'utf8',
25 | collate: 'utf8_general_ci', // 한글 저장
26 | sequelize,
27 | });
28 | }
29 | static associate(db) {
30 | db.User.hasMany(db.Post);
31 | db.User.hasMany(db.Comment);
32 | db.User.belongsToMany(db.Post, { through: 'Like', as: 'Liked' })
33 | db.User.belongsToMany(db.User, { through: 'Follow', as: 'Followers', foreignKey: 'FollowingId' });
34 | db.User.belongsToMany(db.User, { through: 'Follow', as: 'Followings', foreignKey: 'FollowerId' });
35 | }
36 | };
37 |
--------------------------------------------------------------------------------
/ch7/back/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-nodebird-back",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "app.js",
6 | "scripts": {
7 | "dev": "nodemon app",
8 | "start": "cross-env NODE_ENV=production pm2 start app.js"
9 | },
10 | "author": "ZeroCho",
11 | "license": "ISC",
12 | "dependencies": {
13 | "aws-sdk": "^2.710.0",
14 | "bcrypt": "^5.0.0",
15 | "cookie-parser": "^1.4.5",
16 | "cors": "^2.8.5",
17 | "cross-env": "^7.0.2",
18 | "dotenv": "^8.2.0",
19 | "express": "^4.17.1",
20 | "express-session": "^1.17.1",
21 | "helmet": "^3.23.3",
22 | "hpp": "^0.2.3",
23 | "morgan": "^1.10.0",
24 | "multer": "^1.4.2",
25 | "multer-s3": "^2.9.0",
26 | "mysql2": "^2.1.0",
27 | "passport": "^0.4.1",
28 | "passport-local": "^1.0.0",
29 | "pm2": "^5.2.2",
30 | "sequelize": "^6.29.0",
31 | "sequelize-cli": "^5.5.1"
32 | },
33 | "devDependencies": {
34 | "nodemon": "^2.0.4"
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/ch7/back/passport/index.js:
--------------------------------------------------------------------------------
1 | const passport = require('passport');
2 | const local = require('./local');
3 | const { User } = require('../models');
4 |
5 | module.exports = () => {
6 | passport.serializeUser((user, done) => {
7 | done(null, user.id);
8 | });
9 |
10 | passport.deserializeUser(async (id, done) => {
11 | try {
12 | const user = await User.findOne({ where: { id }});
13 | done(null, user); // req.user
14 | } catch (error) {
15 | console.error(error);
16 | done(error);
17 | }
18 | });
19 |
20 | local();
21 | };
22 |
--------------------------------------------------------------------------------
/ch7/back/passport/local.js:
--------------------------------------------------------------------------------
1 | const passport = require('passport');
2 | const { Strategy: LocalStrategy } = require('passport-local');
3 | const bcrypt = require('bcrypt');
4 | const { User } = require('../models');
5 |
6 | module.exports = () => {
7 | passport.use(new LocalStrategy({
8 | usernameField: 'email',
9 | passwordField: 'password',
10 | }, async (email, password, done) => {
11 | try {
12 | const user = await User.findOne({
13 | where: { email }
14 | });
15 | if (!user) {
16 | return done(null, false, { reason: '존재하지 않는 이메일입니다!' });
17 | }
18 | const result = await bcrypt.compare(password, user.password);
19 | if (result) {
20 | return done(null, user);
21 | }
22 | return done(null, false, { reason: '비밀번호가 틀렸습니다.' });
23 | } catch (error) {
24 | console.error(error);
25 | return done(error);
26 | }
27 | }));
28 | };
29 |
--------------------------------------------------------------------------------
/ch7/back/routes/middlewares.js:
--------------------------------------------------------------------------------
1 | exports.isLoggedIn = (req, res, next) => {
2 | if (req.isAuthenticated()) {
3 | next();
4 | } else {
5 | res.status(401).send('로그인이 필요합니다.');
6 | }
7 | };
8 |
9 | exports.isNotLoggedIn = (req, res, next) => {
10 | if (!req.isAuthenticated()) {
11 | next();
12 | } else {
13 | res.status(401).send('로그인하지 않은 사용자만 접근 가능합니다.');
14 | }
15 | };
16 |
--------------------------------------------------------------------------------
/ch7/front/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["next/babel"],
3 | "plugins": [
4 | ["styled-components", {
5 | "ssr": true,
6 | "displayName": true
7 | }]
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------
/ch7/front/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "babel-eslint",
3 | "parserOptions": {
4 | "ecmaVersion": 2020,
5 | "sourceType": "module",
6 | "ecmaFeatures": {
7 | "jsx": true
8 | }
9 | },
10 | "env": {
11 | "browser": true,
12 | "node": true,
13 | "es6": true
14 | },
15 | "extends": [
16 | "airbnb"
17 | ],
18 | "plugins": [
19 | "import",
20 | "react-hooks",
21 | "jsx-a11y"
22 | ],
23 | "rules": {
24 | "jsx-a11y/label-has-associated-control": "off",
25 | "jsx-a11y/anchor-is-valid": "off",
26 | "no-console": "off",
27 | "no-underscore-dangle": "off",
28 | "react/forbid-prop-types": "off",
29 | "react/jsx-filename-extension": "off",
30 | "react/jsx-one-expression-per-line": "off",
31 | "object-curly-newline": "off",
32 | "linebreak-style": "off",
33 | "no-param-reassign": "off"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/ch7/front/components/FollowButton.js:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from 'react';
2 | import { Button } from 'antd';
3 | import PropTypes from 'prop-types';
4 | import { useSelector, useDispatch } from 'react-redux';
5 | import { FOLLOW_REQUEST, UNFOLLOW_REQUEST } from '../reducers/user';
6 |
7 | const FollowButton = ({ post }) => {
8 | const dispatch = useDispatch();
9 | const { me, followLoading, unfollowLoading } = useSelector((state) => state.user);
10 | const isFollowing = me?.Followings.find((v) => v.id === post.User.id);
11 | const onClickButton = useCallback(() => {
12 | if (isFollowing) {
13 | dispatch({
14 | type: UNFOLLOW_REQUEST,
15 | data: post.User.id,
16 | });
17 | } else {
18 | dispatch({
19 | type: FOLLOW_REQUEST,
20 | data: post.User.id,
21 | });
22 | }
23 | }, [isFollowing]);
24 |
25 | if (post.User.id === me.id) {
26 | return null;
27 | }
28 | return (
29 |
32 | );
33 | };
34 |
35 | FollowButton.propTypes = {
36 | post: PropTypes.object.isRequired,
37 | };
38 |
39 | export default FollowButton;
40 |
--------------------------------------------------------------------------------
/ch7/front/components/NicknameEditForm.js:
--------------------------------------------------------------------------------
1 | import { Form, Input } from 'antd';
2 | import React, { useCallback } from 'react';
3 | import { useSelector, useDispatch } from 'react-redux';
4 |
5 | import useInput from '../hooks/useInput';
6 | import { CHANGE_NICKNAME_REQUEST } from '../reducers/user';
7 |
8 | const NicknameEditForm = () => {
9 | const { me } = useSelector((state) => state.user);
10 | const [nickname, onChangeNickname] = useInput(me?.nickname || '');
11 | const dispatch = useDispatch();
12 |
13 | const onSubmit = useCallback(() => {
14 | dispatch({
15 | type: CHANGE_NICKNAME_REQUEST,
16 | data: nickname,
17 | });
18 | }, [nickname]);
19 |
20 | return (
21 |
30 | );
31 | };
32 |
33 | export default NicknameEditForm;
34 |
--------------------------------------------------------------------------------
/ch7/front/components/UserProfile.js:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from 'react';
2 | import { useDispatch, useSelector } from 'react-redux';
3 | import { Card, Avatar, Button } from 'antd';
4 | import Link from 'next/link';
5 |
6 | import { logoutRequestAction } from '../reducers/user';
7 |
8 | const UserProfile = () => {
9 | const dispatch = useDispatch();
10 | const { me, logOutLoading } = useSelector((state) => state.user);
11 |
12 | const onLogOut = useCallback(() => {
13 | dispatch(logoutRequestAction());
14 | }, []);
15 |
16 | return (
17 | 짹짹
{me.Posts.length},
20 | ,
21 | ,
22 | ]}
23 | >
24 |
27 | {me.nickname[0]}
28 |
29 | )}
30 | title={me.nickname}
31 | />
32 |
33 |
34 | );
35 | };
36 |
37 | export default UserProfile;
38 |
--------------------------------------------------------------------------------
/ch7/front/config/config.js:
--------------------------------------------------------------------------------
1 | export const backUrl = process.env.NODE_ENV === 'production' ? 'http://api.nodebird.com' : 'http://localhost:3065';
2 |
--------------------------------------------------------------------------------
/ch7/front/hooks/useInput.js:
--------------------------------------------------------------------------------
1 | import { useState, useCallback } from 'react';
2 |
3 | export default (initialValue = null) => {
4 | const [value, setValue] = useState(initialValue);
5 | const handler = useCallback((e) => {
6 | setValue(e.target.value);
7 | }, []);
8 | return [value, handler, setValue];
9 | }
10 |
--------------------------------------------------------------------------------
/ch7/front/next.config.js:
--------------------------------------------------------------------------------
1 | const withBundleAnalyzer = require('@next/bundle-analyzer')({
2 | enabled: process.env.ANALYZE === 'true',
3 | });
4 |
5 | module.exports = withBundleAnalyzer({
6 | compress: true,
7 | webpack(config, { webpack }) {
8 | const prod = process.env.NODE_ENV === 'production';
9 | return {
10 | ...config,
11 | mode: prod ? 'production' : 'development',
12 | devtool: prod ? 'hidden-source-map' : 'eval',
13 | plugins: [
14 | ...config.plugins,
15 | new webpack.ContextReplacementPlugin(/moment[/\\]locale$/, /^\.\/ko$/),
16 | ],
17 | };
18 | },
19 | });
20 |
--------------------------------------------------------------------------------
/ch7/front/pages/_app.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import Head from 'next/head';
4 | import 'antd/dist/antd.css';
5 |
6 | import wrapper from '../store/configureStore';
7 |
8 | const NodeBird = ({ Component }) => (
9 | <>
10 |
11 |
12 | NodeBird
13 |
14 |
15 | >
16 | );
17 |
18 | NodeBird.propTypes = {
19 | Component: PropTypes.elementType.isRequired,
20 | };
21 |
22 | export function reportWebVitals(metric) {
23 | console.log(metric);
24 | }
25 |
26 | export default wrapper.withRedux(NodeBird);
27 |
--------------------------------------------------------------------------------
/ch7/front/pages/_document.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Document, { Html, Head, Main, NextScript } from 'next/document';
3 | import { ServerStyleSheet } from 'styled-components';
4 |
5 | export default class MyDocument extends Document {
6 | static async getInitialProps(ctx) {
7 | const sheet = new ServerStyleSheet();
8 | const originalRenderPage = ctx.renderPage;
9 | try {
10 | ctx.renderPage = () => originalRenderPage({
11 | enhanceApp: App => props => sheet.collectStyles(),
12 | });
13 | const initialProps = await Document.getInitialProps(ctx);
14 | return {
15 | ...initialProps,
16 | styles: (
17 | <>
18 | {initialProps.styles}
19 | {sheet.getStyleElement()}
20 | >
21 | ),
22 | };
23 | } catch (error) {
24 | console.error(error);
25 | } finally {
26 | sheet.seal();
27 | }
28 | }
29 |
30 | render() {
31 | return (
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | );
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/ch7/front/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ZeroCho/react-nodebird/54bc0260fa5b78c9b7a460ca8832b485cdd2ec82/ch7/front/public/favicon.ico
--------------------------------------------------------------------------------
/ch7/front/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { HYDRATE } from 'next-redux-wrapper';
2 | import { combineReducers } from 'redux';
3 |
4 | import user from './user';
5 | import post from './post';
6 |
7 | // (이전상태, 액션) => 다음상태
8 | const rootReducer = (state, action) => {
9 | switch (action.type) {
10 | case HYDRATE:
11 | console.log('HYDRATE', action);
12 | return action.payload;
13 | default: {
14 | const combinedReducer = combineReducers({
15 | user,
16 | post,
17 | });
18 | return combinedReducer(state, action);
19 | }
20 | }
21 | };
22 |
23 | export default rootReducer;
24 |
--------------------------------------------------------------------------------
/ch7/front/sagas/index.js:
--------------------------------------------------------------------------------
1 | import { all, fork } from 'redux-saga/effects';
2 | import axios from 'axios';
3 |
4 | import postSaga from './post';
5 | import userSaga from './user';
6 | import { backUrl } from '../config/config';
7 |
8 | axios.defaults.baseURL = backUrl;
9 | axios.defaults.withCredentials = true;
10 |
11 | export default function* rootSaga() {
12 | yield all([
13 | fork(postSaga),
14 | fork(userSaga),
15 | ]);
16 | }
17 |
--------------------------------------------------------------------------------
/ch7/front/store/configureStore.js:
--------------------------------------------------------------------------------
1 | import { createWrapper } from 'next-redux-wrapper';
2 | import { applyMiddleware, createStore, compose } from 'redux';
3 | import { composeWithDevTools } from 'redux-devtools-extension';
4 | import createSagaMiddleware from 'redux-saga';
5 |
6 | import reducer from '../reducers';
7 | import rootSaga from '../sagas';
8 |
9 | const loggerMiddleware = ({ dispatch, getState }) => (next) => (action) => {
10 | console.log(action);
11 | return next(action);
12 | };
13 |
14 | const configureStore = () => {
15 | const sagaMiddleware = createSagaMiddleware();
16 | const middlewares = [sagaMiddleware, loggerMiddleware];
17 | const enhancer = process.env.NODE_ENV === 'production'
18 | ? compose(applyMiddleware(...middlewares))
19 | : composeWithDevTools(applyMiddleware(...middlewares));
20 | const store = createStore(reducer, enhancer);
21 | store.sagaTask = sagaMiddleware.run(rootSaga);
22 | return store;
23 | };
24 |
25 | const wrapper = createWrapper(configureStore, {
26 | debug: process.env.NODE_ENV === 'development',
27 | });
28 |
29 | export default wrapper;
30 |
--------------------------------------------------------------------------------
/ch7/front/util/produce.js:
--------------------------------------------------------------------------------
1 | import { enableES5, produce } from 'immer';
2 |
3 | export default (...args) => {
4 | enableES5();
5 | return produce(...args);
6 | };
7 |
--------------------------------------------------------------------------------
/ch7/lambda/index.js:
--------------------------------------------------------------------------------
1 | const AWS = require('aws-sdk');
2 | const sharp = require('sharp');
3 |
4 | const s3 = new AWS.S3();
5 |
6 | exports.handler = async (event, context, callback) => {
7 | const Bucket = event.Records[0].s3.bucket.name; // react-nodebird-s3
8 | const Key = decodeURIComponent(event.Records[0].s3.object.key); // original/12312312_abc.png
9 | console.log(Bucket, Key);
10 | const filename = Key.split('/')[Key.split('/').length - 1];
11 | const ext = Key.split('.')[Key.split('.').length - 1].toLowerCase();
12 | const requiredFormat = ext === 'jpg' ? 'jpeg' : ext;
13 | console.log('filename', filename, 'ext', ext);
14 |
15 | try {
16 | const s3Object = await s3.getObject({ Bucket, Key }).promise();
17 | console.log('original', s3Object.Body.length);
18 | const resizedImage = await sharp(s3Object.Body)
19 | .resize(400, 400, { fit: 'inside' })
20 | .toFormat(requiredFormat)
21 | .toBuffer();
22 | await s3.putObject({
23 | Bucket,
24 | Key: `thumb/${filename}`,
25 | Body: resizedImage,
26 | }).promise();
27 | console.log('put', resizedImage.length);
28 | return callback(null, `thumb/${filename}`);
29 | } catch (error) {
30 | console.error(error)
31 | return callback(error);
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/ch7/lambda/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "lambda",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1"
8 | },
9 | "author": "ZeroCho",
10 | "license": "ISC",
11 | "dependencies": {
12 | "aws-sdk": "^2.710.0",
13 | "sharp": "^0.25.4"
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/https/back/config/config.js:
--------------------------------------------------------------------------------
1 | const dotenv = require('dotenv');
2 |
3 | dotenv.config();
4 |
5 | module.exports = {
6 | development: {
7 | username: 'root',
8 | password: process.env.DB_PASSWORD,
9 | database: 'react-nodebird',
10 | host: '127.0.0.1',
11 | dialect: 'mysql',
12 | },
13 | test: {
14 | username: 'root',
15 | password: process.env.DB_PASSWORD,
16 | database: 'react-nodebird',
17 | host: '127.0.0.1',
18 | dialect: 'mysql',
19 | },
20 | production: {
21 | username: 'root',
22 | password: process.env.DB_PASSWORD,
23 | database: 'react-nodebird',
24 | host: '127.0.0.1',
25 | dialect: 'mysql',
26 | },
27 | };
28 |
--------------------------------------------------------------------------------
/https/back/models/comment.js:
--------------------------------------------------------------------------------
1 | const DataTypes = require('sequelize');
2 | const { Model } = DataTypes;
3 |
4 | module.exports = class Comment extends Model {
5 | static init(sequelize) {
6 | return super.init({
7 | // id가 기본적으로 들어있다.
8 | content: {
9 | type: DataTypes.TEXT,
10 | allowNull: false,
11 | },
12 | // UserId: 1
13 | // PostId: 3
14 | }, {
15 | modelName: 'Comment',
16 | tableName: 'comments',
17 | charset: 'utf8mb4',
18 | collate: 'utf8mb4_general_ci', // 이모티콘 저장
19 | sequelize,
20 | });
21 | }
22 |
23 | static associate(db) {
24 | db.Comment.belongsTo(db.User);
25 | db.Comment.belongsTo(db.Post);
26 | }
27 | };
28 |
--------------------------------------------------------------------------------
/https/back/models/hashtag.js:
--------------------------------------------------------------------------------
1 | const DataTypes = require('sequelize');
2 | const { Model } = DataTypes;
3 |
4 | module.exports = class Hashtag extends Model {
5 | static init(sequelize) {
6 | return super.init({
7 | // id가 기본적으로 들어있다.
8 | name: {
9 | type: DataTypes.STRING(20),
10 | allowNull: false,
11 | },
12 | }, {
13 | modelName: 'Hashtag',
14 | tableName: 'hashtags',
15 | charset: 'utf8mb4',
16 | collate: 'utf8mb4_general_ci', // 이모티콘 저장
17 | sequelize,
18 | });
19 | }
20 | static associate(db) {
21 | db.Hashtag.belongsToMany(db.Post, { through: 'PostHashtag' });
22 | }
23 | };
24 |
--------------------------------------------------------------------------------
/https/back/models/image.js:
--------------------------------------------------------------------------------
1 | const DataTypes = require('sequelize');
2 | const { Model } = DataTypes;
3 |
4 | module.exports = class Image extends Model {
5 | static init(sequelize) {
6 | return super.init({
7 | // id가 기본적으로 들어있다.
8 | src: {
9 | type: DataTypes.STRING(200),
10 | allowNull: false,
11 | },
12 | }, {
13 | modelName: 'Image',
14 | tableName: 'images',
15 | charset: 'utf8',
16 | collate: 'utf8_general_ci',
17 | sequelize,
18 | });
19 | }
20 | static associate(db) {
21 | db.Image.belongsTo(db.Post);
22 | }
23 | };
24 |
--------------------------------------------------------------------------------
/https/back/models/index.js:
--------------------------------------------------------------------------------
1 | const Sequelize = require('sequelize');
2 | const comment = require('./comment');
3 | const hashtag = require('./hashtag');
4 | const image = require('./image');
5 | const post = require('./post');
6 | const user = require('./user');
7 | const report = require('./report');
8 |
9 | const env = process.env.NODE_ENV || 'development';
10 | const config = require('../config/config')[env];
11 | const db = {};
12 |
13 | const sequelize = new Sequelize(config.database, config.username, config.password, config);
14 |
15 | db.Comment = comment;
16 | db.Hashtag = hashtag;
17 | db.Image = image;
18 | db.Post = post;
19 | db.User = user;
20 | db.Report = report;
21 |
22 | Object.keys(db).forEach(modelName => {
23 | db[modelName].init(sequelize);
24 | })
25 |
26 | Object.keys(db).forEach(modelName => {
27 | if (db[modelName].associate) {
28 | db[modelName].associate(db);
29 | }
30 | });
31 |
32 | db.sequelize = sequelize;
33 | db.Sequelize = Sequelize;
34 |
35 | module.exports = db;
36 |
--------------------------------------------------------------------------------
/https/back/models/post.js:
--------------------------------------------------------------------------------
1 | const DataTypes = require('sequelize');
2 | const { Model } = DataTypes;
3 |
4 | module.exports = class Post extends Model {
5 | static init(sequelize) {
6 | return super.init({
7 | // id가 기본적으로 들어있다.
8 | content: {
9 | type: DataTypes.TEXT,
10 | allowNull: false,
11 | },
12 | // RetweetId
13 | }, {
14 | modelName: 'Post',
15 | tableName: 'posts',
16 | charset: 'utf8mb4',
17 | collate: 'utf8mb4_general_ci', // 이모티콘 저장
18 | sequelize,
19 | });
20 | }
21 | static associate(db) {
22 | db.Post.belongsTo(db.User); // post.addUser, post.getUser, post.setUser
23 | db.Post.belongsToMany(db.Hashtag, { through: 'PostHashtag' }); // post.addHashtags
24 | db.Post.hasMany(db.Report);
25 | db.Post.hasMany(db.Comment); // post.addComments, post.getComments
26 | db.Post.hasMany(db.Image); // post.addImages, post.getImages
27 | db.Post.belongsToMany(db.User, { through: 'Like', as: 'Likers' }) // post.addLikers, post.removeLikers
28 | db.Post.belongsTo(db.Post, { as: 'Retweet' }); // post.addRetweet
29 | }
30 | };
31 |
--------------------------------------------------------------------------------
/https/back/models/report.js:
--------------------------------------------------------------------------------
1 | const DataTypes = require('sequelize');
2 | const { Model } = DataTypes;
3 |
4 | module.exports = class Report extends Model {
5 | static init(sequelize) {
6 | return super.init({
7 | // id가 기본적으로 들어있다.
8 | content: {
9 | type: DataTypes.TEXT,
10 | allowNull: false,
11 | },
12 | }, {
13 | modelName: 'Report',
14 | tableName: 'reports',
15 | charset: 'utf8mb4',
16 | collate: 'utf8mb4_general_ci', // 이모티콘 저장
17 | sequelize,
18 | });
19 | }
20 | static associate(db) {
21 | db.Report.belongsTo(db.User);
22 | db.Report.belongsTo(db.Post);
23 | }
24 | };
25 |
--------------------------------------------------------------------------------
/https/back/models/user.js:
--------------------------------------------------------------------------------
1 | const DataTypes = require('sequelize');
2 | const { Model } = DataTypes;
3 |
4 | module.exports = class User extends Model {
5 | static init(sequelize) {
6 | return super.init({
7 | // id가 기본적으로 들어있다.
8 | email: {
9 | type: DataTypes.STRING(30), // STRING, TEXT, BOOLEAN, INTEGER, FLOAT, DATETIME
10 | allowNull: false, // 필수
11 | unique: true, // 고유한 값
12 | },
13 | nickname: {
14 | type: DataTypes.STRING(30),
15 | allowNull: false, // 필수
16 | },
17 | password: {
18 | type: DataTypes.STRING(100),
19 | allowNull: false, // 필수
20 | },
21 | }, {
22 | modelName: 'User',
23 | tableName: 'users',
24 | charset: 'utf8',
25 | collate: 'utf8_general_ci', // 한글 저장
26 | sequelize,
27 | });
28 | }
29 | static associate(db) {
30 | db.User.hasMany(db.Post);
31 | db.User.hasMany(db.Report);
32 | db.User.hasMany(db.Comment);
33 | db.User.belongsToMany(db.Post, { through: 'Like', as: 'Liked' })
34 | db.User.belongsToMany(db.User, { through: 'Follow', as: 'Followers', foreignKey: 'FollowingId' });
35 | db.User.belongsToMany(db.User, { through: 'Follow', as: 'Followings', foreignKey: 'FollowerId' });
36 | }
37 | };
38 |
--------------------------------------------------------------------------------
/https/back/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-nodebird-back",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "app.js",
6 | "scripts": {
7 | "dev": "nodemon app",
8 | "start": "cross-env NODE_ENV=production pm2 start app.js"
9 | },
10 | "author": "ZeroCho",
11 | "license": "ISC",
12 | "dependencies": {
13 | "aws-sdk": "^2.766.0",
14 | "bcrypt": "^5.0.0",
15 | "cookie-parser": "^1.4.5",
16 | "cors": "^2.8.5",
17 | "cross-env": "^7.0.2",
18 | "dotenv": "^8.2.0",
19 | "express": "^4.17.1",
20 | "express-session": "^1.17.1",
21 | "helmet": "^3.23.3",
22 | "hpp": "^0.2.3",
23 | "morgan": "^1.10.0",
24 | "multer": "^1.4.2",
25 | "multer-s3": "^2.9.0",
26 | "mysql2": "^2.2.5",
27 | "nodemailer": "^6.4.13",
28 | "passport": "^0.4.1",
29 | "passport-local": "^1.0.0",
30 | "pm2": "^5.2.2",
31 | "sequelize": "^6.29.0",
32 | "sequelize-cli": "^5.5.1"
33 | },
34 | "devDependencies": {
35 | "nodemon": "^2.0.4"
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/https/back/passport/index.js:
--------------------------------------------------------------------------------
1 | const passport = require('passport');
2 | const local = require('./local');
3 | const { User } = require('../models');
4 |
5 | module.exports = () => {
6 | passport.serializeUser((user, done) => {
7 | done(null, user.id);
8 | });
9 |
10 | passport.deserializeUser(async (id, done) => {
11 | try {
12 | const user = await User.findOne({ where: { id }});
13 | done(null, user); // req.user
14 | } catch (error) {
15 | console.error(error);
16 | done(error);
17 | }
18 | });
19 |
20 | local();
21 | };
22 |
--------------------------------------------------------------------------------
/https/back/passport/local.js:
--------------------------------------------------------------------------------
1 | const passport = require('passport');
2 | const { Strategy: LocalStrategy } = require('passport-local');
3 | const bcrypt = require('bcrypt');
4 | const { User } = require('../models');
5 |
6 | module.exports = () => {
7 | passport.use(new LocalStrategy({
8 | usernameField: 'email',
9 | passwordField: 'password',
10 | }, async (email, password, done) => {
11 | try {
12 | const user = await User.findOne({
13 | where: { email }
14 | });
15 | if (!user) {
16 | return done(null, false, { reason: '존재하지 않는 이메일입니다!' });
17 | }
18 | const result = await bcrypt.compare(password, user.password);
19 | if (result) {
20 | return done(null, user);
21 | }
22 | return done(null, false, { reason: '비밀번호가 틀렸습니다.' });
23 | } catch (error) {
24 | console.error(error);
25 | return done(error);
26 | }
27 | }));
28 | };
29 |
--------------------------------------------------------------------------------
/https/back/routes/middlewares.js:
--------------------------------------------------------------------------------
1 | exports.isLoggedIn = (req, res, next) => {
2 | if (req.isAuthenticated()) {
3 | next();
4 | } else {
5 | res.status(401).send('로그인이 필요합니다.');
6 | }
7 | };
8 |
9 | exports.isNotLoggedIn = (req, res, next) => {
10 | if (!req.isAuthenticated()) {
11 | next();
12 | } else {
13 | res.status(401).send('로그인하지 않은 사용자만 접근 가능합니다.');
14 | }
15 | };
16 |
--------------------------------------------------------------------------------
/https/front/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["next/babel"],
3 | "plugins": [
4 | ["babel-plugin-styled-components", {
5 | "ssr": true,
6 | "displayName": true
7 | }]
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------
/https/front/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "babel-eslint",
3 | "parserOptions": {
4 | "ecmaVersion": 2020,
5 | "sourceType": "module",
6 | "ecmaFeatures": {
7 | "jsx": true
8 | }
9 | },
10 | "env": {
11 | "browser": true,
12 | "node": true,
13 | "es6": true
14 | },
15 | "extends": [
16 | "airbnb"
17 | ],
18 | "plugins": [
19 | "import",
20 | "react-hooks",
21 | "jsx-a11y"
22 | ],
23 | "rules": {
24 | "jsx-a11y/label-has-associated-control": "off",
25 | "jsx-a11y/anchor-is-valid": "off",
26 | "no-console": "off",
27 | "no-underscore-dangle": "off",
28 | "react/forbid-prop-types": "off",
29 | "react/jsx-filename-extension": "off",
30 | "react/jsx-one-expression-per-line": "off",
31 | "object-curly-newline": "off",
32 | "linebreak-style": "off",
33 | "no-param-reassign": "off"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/https/front/components/FollowButton.js:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from 'react';
2 | import { Button } from 'antd';
3 | import PropTypes from 'prop-types';
4 | import { useSelector, useDispatch } from 'react-redux';
5 | import { FOLLOW_REQUEST, UNFOLLOW_REQUEST } from '../reducers/user';
6 |
7 | const FollowButton = ({ post }) => {
8 | const dispatch = useDispatch();
9 | const { me, followLoading, unfollowLoading } = useSelector((state) => state.user);
10 | const isFollowing = me?.Followings.find((v) => v.id === post.User.id);
11 | const onClickButton = useCallback(() => {
12 | if (isFollowing) {
13 | dispatch({
14 | type: UNFOLLOW_REQUEST,
15 | data: post.User.id,
16 | });
17 | } else {
18 | dispatch({
19 | type: FOLLOW_REQUEST,
20 | data: post.User.id,
21 | });
22 | }
23 | }, [isFollowing]);
24 |
25 | if (post.User.id === me.id) {
26 | return null;
27 | }
28 | return (
29 |
32 | );
33 | };
34 |
35 | FollowButton.propTypes = {
36 | post: PropTypes.object.isRequired,
37 | };
38 |
39 | export default FollowButton;
40 |
--------------------------------------------------------------------------------
/https/front/components/NicknameEditForm.js:
--------------------------------------------------------------------------------
1 | import { Form, Input } from 'antd';
2 | import React, { useCallback } from 'react';
3 | import { useSelector, useDispatch } from 'react-redux';
4 |
5 | import useInput from '../hooks/useInput';
6 | import { CHANGE_NICKNAME_REQUEST } from '../reducers/user';
7 |
8 | const NicknameEditForm = () => {
9 | const { me } = useSelector((state) => state.user);
10 | const [nickname, onChangeNickname] = useInput(me?.nickname || '');
11 | const dispatch = useDispatch();
12 |
13 | const onSubmit = useCallback(() => {
14 | dispatch({
15 | type: CHANGE_NICKNAME_REQUEST,
16 | data: nickname,
17 | });
18 | }, [nickname]);
19 |
20 | return (
21 |
30 | );
31 | };
32 |
33 | export default NicknameEditForm;
34 |
--------------------------------------------------------------------------------
/https/front/components/UserProfile.js:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from 'react';
2 | import { useDispatch, useSelector } from 'react-redux';
3 | import { Card, Avatar, Button } from 'antd';
4 | import Link from 'next/link';
5 |
6 | import { logoutRequestAction } from '../reducers/user';
7 |
8 | const UserProfile = () => {
9 | const dispatch = useDispatch();
10 | const { me, logOutLoading } = useSelector((state) => state.user);
11 |
12 | const onLogOut = useCallback(() => {
13 | dispatch(logoutRequestAction());
14 | }, []);
15 |
16 | return (
17 | 짹짹
{me.Posts.length},
20 | ,
21 | ,
22 | ]}
23 | >
24 |
27 | {me.nickname[0]}
28 |
29 | )}
30 | title={me.nickname}
31 | />
32 |
33 |
34 | );
35 | };
36 |
37 | export default UserProfile;
38 |
--------------------------------------------------------------------------------
/https/front/config/config.js:
--------------------------------------------------------------------------------
1 | export const backUrl = process.env.NODE_ENV === 'production' ? 'https://api.nodebird.com' : 'http://localhost:3065';
2 |
--------------------------------------------------------------------------------
/https/front/hooks/useInput.js:
--------------------------------------------------------------------------------
1 | import { useState, useCallback } from 'react';
2 |
3 | const useInput = (initialValue = null) => {
4 | const [value, setValue] = useState(initialValue);
5 | const handler = useCallback((e) => {
6 | setValue(e.target.value);
7 | }, []);
8 | return [value, handler, setValue];
9 | };
10 | export default useInput;
11 |
--------------------------------------------------------------------------------
/https/front/next.config.js:
--------------------------------------------------------------------------------
1 | const withBundleAnalyzer = require('@next/bundle-analyzer')({
2 | enabled: process.env.ANALYZE === 'true',
3 | });
4 |
5 | module.exports = withBundleAnalyzer({
6 | compress: true,
7 | webpack(config, { webpack }) {
8 | const prod = process.env.NODE_ENV === 'production';
9 | const newConfig = {
10 | ...config,
11 | mode: prod ? 'production' : 'development',
12 | plugins: [
13 | ...config.plugins,
14 | new webpack.ContextReplacementPlugin(/moment[/\\]locale$/, /^\.\/ko$/),
15 | ],
16 | };
17 | if (prod) {
18 | newConfig.devtool = 'hidden-source-map';
19 | }
20 | return newConfig;
21 | },
22 | });
23 |
--------------------------------------------------------------------------------
/https/front/pages/_app.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import Head from 'next/head';
4 | import 'antd/dist/antd.css';
5 |
6 | import wrapper from '../store/configureStore';
7 |
8 | const NodeBird = ({ Component }) => (
9 | <>
10 |
11 |
12 | NodeBird
13 |
14 |
15 | >
16 | );
17 |
18 | NodeBird.propTypes = {
19 | Component: PropTypes.elementType.isRequired,
20 | };
21 |
22 | export function reportWebVitals(metric) {
23 | console.log(metric);
24 | }
25 |
26 | export default wrapper.withRedux(NodeBird);
27 |
--------------------------------------------------------------------------------
/https/front/pages/_document.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Document, { Html, Head, Main, NextScript } from 'next/document';
3 | import { ServerStyleSheet } from 'styled-components';
4 |
5 | export default class MyDocument extends Document {
6 | static async getInitalProps(ctx) {
7 | const sheet = new ServerStyleSheet();
8 | const originalRenderPage = ctx.renderPage;
9 | try {
10 | ctx.renderPage = () => originalRenderPage({
11 | enhanceApp: (App) => (props) => sheet.collectStyles(),
12 | });
13 | const initialProps = await Document.getInitialProps(ctx);
14 | return {
15 | ...initialProps,
16 | styles: (
17 | <>
18 | {initialProps.styles}
19 | {sheet.getStyleElement()}
20 | >
21 | ),
22 | };
23 | } catch (error) {
24 | console.error(error);
25 | } finally {
26 | sheet.seal();
27 | }
28 | }
29 |
30 | render() {
31 | return (
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | );
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/https/front/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ZeroCho/react-nodebird/54bc0260fa5b78c9b7a460ca8832b485cdd2ec82/https/front/public/favicon.ico
--------------------------------------------------------------------------------
/https/front/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { HYDRATE } from 'next-redux-wrapper';
2 | import { combineReducers } from 'redux';
3 |
4 | import user from './user';
5 | import post from './post';
6 |
7 | // (이전상태, 액션) => 다음상태
8 | const rootReducer = (state, action) => {
9 | switch (action.type) {
10 | case HYDRATE:
11 | return action.payload;
12 | default: {
13 | const combinedReducer = combineReducers({
14 | user,
15 | post,
16 | });
17 | return combinedReducer(state, action);
18 | }
19 | }
20 | };
21 |
22 | export default rootReducer;
23 |
--------------------------------------------------------------------------------
/https/front/sagas/index.js:
--------------------------------------------------------------------------------
1 | import { all, fork } from 'redux-saga/effects';
2 | import axios from 'axios';
3 |
4 | import postSaga from './post';
5 | import userSaga from './user';
6 | import { backUrl } from '../config/config';
7 |
8 | axios.defaults.baseURL = backUrl;
9 | axios.defaults.withCredentials = true;
10 |
11 | export default function* rootSaga() {
12 | yield all([
13 | fork(postSaga),
14 | fork(userSaga),
15 | ]);
16 | }
17 |
--------------------------------------------------------------------------------
/https/front/store/configureStore.js:
--------------------------------------------------------------------------------
1 | import { createWrapper } from 'next-redux-wrapper';
2 | import { applyMiddleware, createStore, compose } from 'redux';
3 | import { composeWithDevTools } from 'redux-devtools-extension';
4 | import createSagaMiddleware from 'redux-saga';
5 |
6 | import reducer from '../reducers';
7 | import rootSaga from '../sagas';
8 |
9 | const loggerMiddleware = ({ dispatch, getState }) => (next) => (action) => {
10 | console.log(action);
11 | return next(action);
12 | };
13 |
14 | const configureStore = () => {
15 | const sagaMiddleware = createSagaMiddleware();
16 | const middlewares = [sagaMiddleware, loggerMiddleware];
17 | const enhancer = process.env.NODE_ENV === 'production'
18 | ? compose(applyMiddleware(...middlewares))
19 | : composeWithDevTools(applyMiddleware(...middlewares));
20 | const store = createStore(reducer, enhancer);
21 | store.sagaTask = sagaMiddleware.run(rootSaga);
22 | return store;
23 | };
24 |
25 | const wrapper = createWrapper(configureStore, {
26 | debug: process.env.NODE_ENV === 'development',
27 | });
28 |
29 | export default wrapper;
30 |
--------------------------------------------------------------------------------
/https/front/util/produce.js:
--------------------------------------------------------------------------------
1 | import { enableES5, produce } from 'immer';
2 |
3 | export default (...args) => {
4 | enableES5();
5 | return produce(...args);
6 | };
7 |
--------------------------------------------------------------------------------
/https/lambda/index.js:
--------------------------------------------------------------------------------
1 | const AWS = require('aws-sdk');
2 | const sharp = require('sharp');
3 |
4 | const s3 = new AWS.S3();
5 |
6 | exports.handler = async (event, context, callback) => {
7 | const Bucket = event.Records[0].s3.bucket.name; // react-nodebird-s3
8 | const Key = decodeURIComponent(event.Records[0].s3.object.key); // original/12312312_abc.png
9 | console.log(Bucket, Key);
10 | const filename = Key.split('/')[Key.split('/').length - 1];
11 | const ext = Key.split('.')[Key.split('.').length - 1].toLowerCase();
12 | const requiredFormat = ext === 'jpg' ? 'jpeg' : ext;
13 | console.log('filename', filename, 'ext', ext);
14 |
15 | try {
16 | const s3Object = await s3.getObject({ Bucket, Key }).promise();
17 | console.log('original', s3Object.Body.length);
18 | const resizedImage = await sharp(s3Object.Body)
19 | .resize(400, 400, { fit: 'inside' })
20 | .toFormat(requiredFormat)
21 | .toBuffer();
22 | await s3.putObject({
23 | Bucket,
24 | Key: `thumb/${filename}`,
25 | Body: resizedImage,
26 | }).promise();
27 | console.log('put', resizedImage.length);
28 | return callback(null, `thumb/${filename}`);
29 | } catch (error) {
30 | console.error(error)
31 | return callback(error);
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/https/lambda/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "lambda",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1"
8 | },
9 | "author": "ZeroCho",
10 | "license": "ISC",
11 | "dependencies": {
12 | "aws-sdk": "^2.710.0",
13 | "sharp": "^0.25.4"
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/intersection/back/config/config.js:
--------------------------------------------------------------------------------
1 | const dotenv = require('dotenv');
2 |
3 | dotenv.config();
4 |
5 | module.exports = {
6 | development: {
7 | username: 'root',
8 | password: process.env.DB_PASSWORD,
9 | database: 'react-nodebird',
10 | host: '127.0.0.1',
11 | dialect: 'mysql',
12 | },
13 | test: {
14 | username: 'root',
15 | password: process.env.DB_PASSWORD,
16 | database: 'react-nodebird',
17 | host: '127.0.0.1',
18 | dialect: 'mysql',
19 | },
20 | production: {
21 | username: 'root',
22 | password: process.env.DB_PASSWORD,
23 | database: 'react-nodebird',
24 | host: '127.0.0.1',
25 | dialect: 'mysql',
26 | },
27 | };
28 |
--------------------------------------------------------------------------------
/intersection/back/models/comment.js:
--------------------------------------------------------------------------------
1 | const DataTypes = require('sequelize');
2 | const { Model } = DataTypes;
3 |
4 | module.exports = class Comment extends Model {
5 | static init(sequelize) {
6 | return super.init({
7 | // id가 기본적으로 들어있다.
8 | content: {
9 | type: DataTypes.TEXT,
10 | allowNull: false,
11 | },
12 | // UserId: 1
13 | // PostId: 3
14 | }, {
15 | modelName: 'Comment',
16 | tableName: 'comments',
17 | charset: 'utf8mb4',
18 | collate: 'utf8mb4_general_ci', // 이모티콘 저장
19 | sequelize,
20 | });
21 | }
22 |
23 | static associate(db) {
24 | db.Comment.belongsTo(db.User);
25 | db.Comment.belongsTo(db.Post);
26 | }
27 | };
28 |
--------------------------------------------------------------------------------
/intersection/back/models/hashtag.js:
--------------------------------------------------------------------------------
1 | const DataTypes = require('sequelize');
2 | const { Model } = DataTypes;
3 |
4 | module.exports = class Hashtag extends Model {
5 | static init(sequelize) {
6 | return super.init({
7 | // id가 기본적으로 들어있다.
8 | name: {
9 | type: DataTypes.STRING(20),
10 | allowNull: false,
11 | },
12 | }, {
13 | modelName: 'Hashtag',
14 | tableName: 'hashtags',
15 | charset: 'utf8mb4',
16 | collate: 'utf8mb4_general_ci', // 이모티콘 저장
17 | sequelize,
18 | });
19 | }
20 | static associate(db) {
21 | db.Hashtag.belongsToMany(db.Post, { through: 'PostHashtag' });
22 | }
23 | };
24 |
--------------------------------------------------------------------------------
/intersection/back/models/image.js:
--------------------------------------------------------------------------------
1 | const DataTypes = require('sequelize');
2 | const { Model } = DataTypes;
3 |
4 | module.exports = class Image extends Model {
5 | static init(sequelize) {
6 | return super.init({
7 | // id가 기본적으로 들어있다.
8 | src: {
9 | type: DataTypes.STRING(200),
10 | allowNull: false,
11 | },
12 | }, {
13 | modelName: 'Image',
14 | tableName: 'images',
15 | charset: 'utf8',
16 | collate: 'utf8_general_ci',
17 | sequelize,
18 | });
19 | }
20 | static associate(db) {
21 | db.Image.belongsTo(db.Post);
22 | }
23 | };
24 |
--------------------------------------------------------------------------------
/intersection/back/models/index.js:
--------------------------------------------------------------------------------
1 | const Sequelize = require('sequelize');
2 | const comment = require('./comment');
3 | const hashtag = require('./hashtag');
4 | const image = require('./image');
5 | const post = require('./post');
6 | const user = require('./user');
7 |
8 | const env = process.env.NODE_ENV || 'development';
9 | const config = require('../config/config')[env];
10 | const db = {};
11 |
12 | const sequelize = new Sequelize(config.database, config.username, config.password, config);
13 |
14 | db.Comment = comment;
15 | db.Hashtag = hashtag;
16 | db.Image = image;
17 | db.Post = post;
18 | db.User = user;
19 |
20 | Object.keys(db).forEach(modelName => {
21 | db[modelName].init(sequelize);
22 | })
23 |
24 | Object.keys(db).forEach(modelName => {
25 | if (db[modelName].associate) {
26 | db[modelName].associate(db);
27 | }
28 | });
29 |
30 | db.sequelize = sequelize;
31 | db.Sequelize = Sequelize;
32 |
33 | module.exports = db;
34 |
--------------------------------------------------------------------------------
/intersection/back/models/post.js:
--------------------------------------------------------------------------------
1 | const DataTypes = require('sequelize');
2 | const { Model } = DataTypes;
3 |
4 | module.exports = class Post extends Model {
5 | static init(sequelize) {
6 | return super.init({
7 | // id가 기본적으로 들어있다.
8 | content: {
9 | type: DataTypes.TEXT,
10 | allowNull: false,
11 | },
12 | // RetweetId
13 | }, {
14 | modelName: 'Post',
15 | tableName: 'posts',
16 | charset: 'utf8mb4',
17 | collate: 'utf8mb4_general_ci', // 이모티콘 저장
18 | sequelize,
19 | });
20 | }
21 | static associate(db) {
22 | db.Post.belongsTo(db.User); // post.addUser, post.getUser, post.setUser
23 | db.Post.belongsToMany(db.Hashtag, { through: 'PostHashtag' }); // post.addHashtags
24 | db.Post.hasMany(db.Comment); // post.addComments, post.getComments
25 | db.Post.hasMany(db.Image); // post.addImages, post.getImages
26 | db.Post.belongsToMany(db.User, { through: 'Like', as: 'Likers' }) // post.addLikers, post.removeLikers
27 | db.Post.belongsTo(db.Post, { as: 'Retweet' }); // post.addRetweet
28 | }
29 | };
30 |
--------------------------------------------------------------------------------
/intersection/back/models/user.js:
--------------------------------------------------------------------------------
1 | const DataTypes = require('sequelize');
2 | const { Model } = DataTypes;
3 |
4 | module.exports = class User extends Model {
5 | static init(sequelize) {
6 | return super.init({
7 | // id가 기본적으로 들어있다.
8 | email: {
9 | type: DataTypes.STRING(30), // STRING, TEXT, BOOLEAN, INTEGER, FLOAT, DATETIME
10 | allowNull: false, // 필수
11 | unique: true, // 고유한 값
12 | },
13 | nickname: {
14 | type: DataTypes.STRING(30),
15 | allowNull: false, // 필수
16 | },
17 | password: {
18 | type: DataTypes.STRING(100),
19 | allowNull: false, // 필수
20 | },
21 | }, {
22 | modelName: 'User',
23 | tableName: 'users',
24 | charset: 'utf8',
25 | collate: 'utf8_general_ci', // 한글 저장
26 | sequelize,
27 | });
28 | }
29 | static associate(db) {
30 | db.User.hasMany(db.Post);
31 | db.User.hasMany(db.Comment);
32 | db.User.belongsToMany(db.Post, { through: 'Like', as: 'Liked' })
33 | db.User.belongsToMany(db.User, { through: 'Follow', as: 'Followers', foreignKey: 'FollowingId' });
34 | db.User.belongsToMany(db.User, { through: 'Follow', as: 'Followings', foreignKey: 'FollowerId' });
35 | }
36 | };
37 |
--------------------------------------------------------------------------------
/intersection/back/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-nodebird-back",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "app.js",
6 | "scripts": {
7 | "dev": "nodemon app",
8 | "start": "cross-env NODE_ENV=production pm2 start app.js"
9 | },
10 | "author": "ZeroCho",
11 | "license": "ISC",
12 | "dependencies": {
13 | "aws-sdk": "^2.710.0",
14 | "bcrypt": "^5.0.0",
15 | "cookie-parser": "^1.4.5",
16 | "cors": "^2.8.5",
17 | "cross-env": "^7.0.2",
18 | "dotenv": "^16.0.0",
19 | "express": "^4.17.1",
20 | "express-session": "^1.17.1",
21 | "helmet": "^5.0.2",
22 | "hpp": "^0.2.3",
23 | "morgan": "^1.10.0",
24 | "multer": "^1.4.2",
25 | "multer-s3": "^2.9.0",
26 | "mysql2": "^2.1.0",
27 | "passport": "^0.5.2",
28 | "passport-local": "^1.0.0",
29 | "pm2": "^5.2.0",
30 | "sequelize": "^6.19.0",
31 | "sequelize-cli": "^6.4.1"
32 | },
33 | "devDependencies": {
34 | "nodemon": "^2.0.4"
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/intersection/back/passport/index.js:
--------------------------------------------------------------------------------
1 | const passport = require('passport');
2 | const local = require('./local');
3 | const { User } = require('../models');
4 |
5 | module.exports = () => {
6 | passport.serializeUser((user, done) => {
7 | done(null, user.id);
8 | });
9 |
10 | passport.deserializeUser(async (id, done) => {
11 | try {
12 | const user = await User.findOne({ where: { id }});
13 | done(null, user); // req.user
14 | } catch (error) {
15 | console.error(error);
16 | done(error);
17 | }
18 | });
19 |
20 | local();
21 | };
22 |
--------------------------------------------------------------------------------
/intersection/back/passport/local.js:
--------------------------------------------------------------------------------
1 | const passport = require('passport');
2 | const { Strategy: LocalStrategy } = require('passport-local');
3 | const bcrypt = require('bcrypt');
4 | const { User } = require('../models');
5 |
6 | module.exports = () => {
7 | passport.use(new LocalStrategy({
8 | usernameField: 'email',
9 | passwordField: 'password',
10 | }, async (email, password, done) => {
11 | try {
12 | const user = await User.findOne({
13 | where: { email }
14 | });
15 | if (!user) {
16 | return done(null, false, { reason: '존재하지 않는 이메일입니다!' });
17 | }
18 | const result = await bcrypt.compare(password, user.password);
19 | if (result) {
20 | return done(null, user);
21 | }
22 | return done(null, false, { reason: '비밀번호가 틀렸습니다.' });
23 | } catch (error) {
24 | console.error(error);
25 | return done(error);
26 | }
27 | }));
28 | };
29 |
--------------------------------------------------------------------------------
/intersection/back/routes/middlewares.js:
--------------------------------------------------------------------------------
1 | exports.isLoggedIn = (req, res, next) => {
2 | if (req.isAuthenticated()) {
3 | next();
4 | } else {
5 | res.status(401).send('로그인이 필요합니다.');
6 | }
7 | };
8 |
9 | exports.isNotLoggedIn = (req, res, next) => {
10 | if (!req.isAuthenticated()) {
11 | next();
12 | } else {
13 | res.status(401).send('로그인하지 않은 사용자만 접근 가능합니다.');
14 | }
15 | };
16 |
--------------------------------------------------------------------------------
/intersection/front/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["next/babel"],
3 | "plugins": [
4 | ["styled-components", {
5 | "ssr": true,
6 | "displayName": true
7 | }]
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------
/intersection/front/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "@babel/eslint-parser",
3 | "parserOptions": {
4 | "ecmaVersion": 2020,
5 | "sourceType": "module",
6 | "ecmaFeatures": {
7 | "jsx": true
8 | }
9 | },
10 | "env": {
11 | "browser": true,
12 | "node": true,
13 | "es6": true
14 | },
15 | "extends": [
16 | "airbnb"
17 | ],
18 | "plugins": [
19 | "import",
20 | "react-hooks",
21 | "jsx-a11y"
22 | ],
23 | "rules": {
24 | "jsx-a11y/label-has-associated-control": "off",
25 | "jsx-a11y/anchor-is-valid": "off",
26 | "no-console": "off",
27 | "no-underscore-dangle": "off",
28 | "react/forbid-prop-types": "off",
29 | "react/jsx-filename-extension": "off",
30 | "react/jsx-one-expression-per-line": "off",
31 | "object-curly-newline": "off",
32 | "linebreak-style": "off",
33 | "no-param-reassign": "off",
34 | "max-len": ["warn", { "code": 120 }]
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/intersection/front/components/FollowButton.js:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from 'react';
2 | import { Button } from 'antd';
3 | import PropTypes from 'prop-types';
4 | import { useSelector, useDispatch } from 'react-redux';
5 | import { FOLLOW_REQUEST, UNFOLLOW_REQUEST } from '../reducers/user';
6 |
7 | function FollowButton({ post }) {
8 | const dispatch = useDispatch();
9 | const { me, followLoading, unfollowLoading } = useSelector((state) => state.user);
10 | const isFollowing = me?.Followings.find((v) => v.id === post.User.id);
11 | const onClickButton = useCallback(() => {
12 | if (isFollowing) {
13 | dispatch({
14 | type: UNFOLLOW_REQUEST,
15 | data: post.User.id,
16 | });
17 | } else {
18 | dispatch({
19 | type: FOLLOW_REQUEST,
20 | data: post.User.id,
21 | });
22 | }
23 | }, [isFollowing]);
24 |
25 | if (post.User.id === me.id) {
26 | return null;
27 | }
28 | return (
29 |
32 | );
33 | }
34 |
35 | FollowButton.propTypes = {
36 | post: PropTypes.object.isRequired,
37 | };
38 |
39 | export default FollowButton;
40 |
--------------------------------------------------------------------------------
/intersection/front/components/NicknameEditForm.js:
--------------------------------------------------------------------------------
1 | import { Form, Input } from 'antd';
2 | import React, { useCallback } from 'react';
3 | import { useSelector, useDispatch } from 'react-redux';
4 |
5 | import useInput from '../hooks/useInput';
6 | import { CHANGE_NICKNAME_REQUEST } from '../reducers/user';
7 |
8 | function NicknameEditForm() {
9 | const { me } = useSelector((state) => state.user);
10 | const [nickname, onChangeNickname] = useInput(me?.nickname || '');
11 | const dispatch = useDispatch();
12 |
13 | const onSubmit = useCallback(() => {
14 | dispatch({
15 | type: CHANGE_NICKNAME_REQUEST,
16 | data: nickname,
17 | });
18 | }, [nickname]);
19 |
20 | return (
21 |
30 | );
31 | }
32 |
33 | export default NicknameEditForm;
34 |
--------------------------------------------------------------------------------
/intersection/front/components/UserProfile.js:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from 'react';
2 | import { useDispatch, useSelector } from 'react-redux';
3 | import { Card, Avatar, Button } from 'antd';
4 | import Link from 'next/link';
5 |
6 | import { logoutRequestAction } from '../reducers/user';
7 |
8 | function UserProfile() {
9 | const dispatch = useDispatch();
10 | const { me, logOutLoading } = useSelector((state) => state.user);
11 |
12 | const onLogOut = useCallback(() => {
13 | dispatch(logoutRequestAction());
14 | }, []);
15 |
16 | return (
17 | 짹짹
{me.Posts.length},
20 | ,
21 | ,
22 | ]}
23 | >
24 |
27 | {me.nickname[0]}
28 |
29 | )}
30 | title={me.nickname}
31 | />
32 |
33 |
34 | );
35 | }
36 |
37 | export default UserProfile;
38 |
--------------------------------------------------------------------------------
/intersection/front/config/config.js:
--------------------------------------------------------------------------------
1 | export const backUrl = process.env.NODE_ENV === 'production' ? 'http://api.nodebird.com' : 'http://localhost:3065';
2 |
--------------------------------------------------------------------------------
/intersection/front/hooks/useInput.js:
--------------------------------------------------------------------------------
1 | import { useState, useCallback } from 'react';
2 |
3 | const useInput = (initialValue = null) => {
4 | const [value, setValue] = useState(initialValue);
5 | const handler = useCallback((e) => {
6 | setValue(e.target.value);
7 | }, []);
8 | return [value, handler, setValue];
9 | };
10 | export default useInput;
11 |
--------------------------------------------------------------------------------
/intersection/front/next.config.js:
--------------------------------------------------------------------------------
1 | const withBundleAnalyzer = require('@next/bundle-analyzer')({
2 | enabled: process.env.ANALYZE === 'true',
3 | });
4 |
5 | module.exports = withBundleAnalyzer({
6 | compress: true,
7 | webpack(config, { webpack }) {
8 | const prod = process.env.NODE_ENV === 'production';
9 | return {
10 | ...config,
11 | mode: prod ? 'production' : 'development',
12 | devtool: prod ? 'hidden-source-map' : 'eval-source-map',
13 | plugins: [
14 | ...config.plugins,
15 | new webpack.ContextReplacementPlugin(/moment[/\\]locale$/, /^\.\/ko$/),
16 | ],
17 | };
18 | },
19 | });
20 |
--------------------------------------------------------------------------------
/intersection/front/pages/_app.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import Head from 'next/head';
4 | import 'antd/dist/antd.css';
5 | import 'slick-carousel/slick/slick.css';
6 | import 'slick-carousel/slick/slick-theme.css';
7 |
8 | import wrapper from '../store/configureStore';
9 |
10 | function NodeBird({ Component }) {
11 | return (
12 | <>
13 |
14 |
15 | NodeBird
16 |
17 |
18 | >
19 | );
20 | }
21 |
22 | NodeBird.propTypes = {
23 | Component: PropTypes.elementType.isRequired,
24 | };
25 |
26 | export function reportWebVitals(metric) {
27 | console.log(metric);
28 | }
29 |
30 | export default wrapper.withRedux(NodeBird);
31 |
--------------------------------------------------------------------------------
/intersection/front/pages/_document.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Document, { Html, Head, Main, NextScript } from 'next/document';
3 | import { ServerStyleSheet } from 'styled-components';
4 |
5 | export default class MyDocument extends Document {
6 | static async getInitialProps(ctx) {
7 | const sheet = new ServerStyleSheet();
8 | const originalRenderPage = ctx.renderPage;
9 | try {
10 | ctx.renderPage = () => originalRenderPage({
11 | enhanceApp: (App) => (props) => sheet.collectStyles(),
12 | });
13 | const initialProps = await Document.getInitialProps(ctx);
14 | return {
15 | ...initialProps,
16 | styles: (
17 | <>
18 | {initialProps.styles}
19 | {sheet.getStyleElement()}
20 | >
21 | ),
22 | };
23 | } catch (error) {
24 | console.error(error);
25 | } finally {
26 | sheet.seal();
27 | }
28 | }
29 |
30 | render() {
31 | return (
32 |
33 |
35 |
36 |
37 |
38 |
39 | );
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/intersection/front/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ZeroCho/react-nodebird/54bc0260fa5b78c9b7a460ca8832b485cdd2ec82/intersection/front/public/favicon.ico
--------------------------------------------------------------------------------
/intersection/front/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { HYDRATE } from 'next-redux-wrapper';
2 | import { combineReducers } from 'redux';
3 |
4 | import user from './user';
5 | import post from './post';
6 |
7 | // (이전상태, 액션) => 다음상태
8 | const rootReducer = (state, action) => {
9 | switch (action.type) {
10 | case HYDRATE:
11 | console.log('HYDRATE', action);
12 | return action.payload;
13 | default: {
14 | const combinedReducer = combineReducers({
15 | user,
16 | post,
17 | });
18 | return combinedReducer(state, action);
19 | }
20 | }
21 | };
22 |
23 | export default rootReducer;
24 |
--------------------------------------------------------------------------------
/intersection/front/sagas/index.js:
--------------------------------------------------------------------------------
1 | import { all, fork } from 'redux-saga/effects';
2 | import axios from 'axios';
3 |
4 | import postSaga from './post';
5 | import userSaga from './user';
6 | import { backUrl } from '../config/config';
7 |
8 | axios.defaults.baseURL = backUrl;
9 | axios.defaults.withCredentials = true;
10 |
11 | export default function* rootSaga() {
12 | yield all([
13 | fork(postSaga),
14 | fork(userSaga),
15 | ]);
16 | }
17 |
--------------------------------------------------------------------------------
/intersection/front/store/configureStore.js:
--------------------------------------------------------------------------------
1 | import { createWrapper } from 'next-redux-wrapper';
2 | import { applyMiddleware, createStore, compose } from 'redux';
3 | import { composeWithDevTools } from 'redux-devtools-extension';
4 | import createSagaMiddleware from 'redux-saga';
5 |
6 | import reducer from '../reducers';
7 | import rootSaga from '../sagas';
8 |
9 | const loggerMiddleware = ({ dispatch, getState }) => (next) => (action) => {
10 | console.log(action);
11 | return next(action);
12 | };
13 |
14 | const configureStore = () => {
15 | const sagaMiddleware = createSagaMiddleware();
16 | const middlewares = [sagaMiddleware, loggerMiddleware];
17 | const enhancer = process.env.NODE_ENV === 'production'
18 | ? compose(applyMiddleware(...middlewares))
19 | : composeWithDevTools(applyMiddleware(...middlewares));
20 | const store = createStore(reducer, enhancer);
21 | store.sagaTask = sagaMiddleware.run(rootSaga);
22 | return store;
23 | };
24 |
25 | const wrapper = createWrapper(configureStore, {
26 | debug: process.env.NODE_ENV === 'development',
27 | });
28 |
29 | export default wrapper;
30 |
--------------------------------------------------------------------------------
/intersection/front/util/produce.js:
--------------------------------------------------------------------------------
1 | import { enableES5, produce } from 'immer';
2 |
3 | const produceExtended = (...args) => {
4 | enableES5();
5 | return produce(...args);
6 | };
7 | export default produceExtended;
8 |
--------------------------------------------------------------------------------
/intersection/lambda/index.js:
--------------------------------------------------------------------------------
1 | const AWS = require('aws-sdk');
2 | const sharp = require('sharp');
3 |
4 | const s3 = new AWS.S3();
5 |
6 | exports.handler = async (event, context, callback) => {
7 | const Bucket = event.Records[0].s3.bucket.name; // react-nodebird-s3
8 | const Key = decodeURIComponent(event.Records[0].s3.object.key); // original/12312312_abc.png
9 | console.log(Bucket, Key);
10 | const filename = Key.split('/')[Key.split('/').length - 1];
11 | const ext = Key.split('.')[Key.split('.').length - 1].toLowerCase();
12 | const requiredFormat = ext === 'jpg' ? 'jpeg' : ext;
13 | console.log('filename', filename, 'ext', ext);
14 |
15 | try {
16 | const s3Object = await s3.getObject({ Bucket, Key }).promise();
17 | console.log('original', s3Object.Body.length);
18 | const resizedImage = await sharp(s3Object.Body)
19 | .resize(400, 400, { fit: 'inside' })
20 | .toFormat(requiredFormat)
21 | .toBuffer();
22 | await s3.putObject({
23 | Bucket,
24 | Key: `thumb/${filename}`,
25 | Body: resizedImage,
26 | }).promise();
27 | console.log('put', resizedImage.length);
28 | return callback(null, `thumb/${filename}`);
29 | } catch (error) {
30 | console.error(error)
31 | return callback(error);
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/intersection/lambda/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "lambda",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1"
8 | },
9 | "author": "ZeroCho",
10 | "license": "ISC",
11 | "dependencies": {
12 | "aws-sdk": "^2.710.0",
13 | "sharp": "^0.25.4"
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/react-query/back/config/config.js:
--------------------------------------------------------------------------------
1 | const dotenv = require('dotenv');
2 |
3 | dotenv.config();
4 |
5 | module.exports = {
6 | development: {
7 | username: 'root',
8 | password: process.env.DB_PASSWORD,
9 | database: 'react-nodebird',
10 | host: '127.0.0.1',
11 | dialect: 'mysql',
12 | },
13 | test: {
14 | username: 'root',
15 | password: process.env.DB_PASSWORD,
16 | database: 'react-nodebird',
17 | host: '127.0.0.1',
18 | dialect: 'mysql',
19 | },
20 | production: {
21 | username: 'root',
22 | password: process.env.DB_PASSWORD,
23 | database: 'react-nodebird',
24 | host: '127.0.0.1',
25 | dialect: 'mysql',
26 | },
27 | };
28 |
--------------------------------------------------------------------------------
/react-query/back/models/comment.js:
--------------------------------------------------------------------------------
1 | const DataTypes = require('sequelize');
2 | const { Model } = DataTypes;
3 |
4 | module.exports = class Comment extends Model {
5 | static init(sequelize) {
6 | return super.init({
7 | // id가 기본적으로 들어있다.
8 | content: {
9 | type: DataTypes.TEXT,
10 | allowNull: false,
11 | },
12 | // UserId: 1
13 | // PostId: 3
14 | }, {
15 | modelName: 'Comment',
16 | tableName: 'comments',
17 | charset: 'utf8mb4',
18 | collate: 'utf8mb4_general_ci', // 이모티콘 저장
19 | sequelize,
20 | });
21 | }
22 |
23 | static associate(db) {
24 | db.Comment.belongsTo(db.User);
25 | db.Comment.belongsTo(db.Post);
26 | }
27 | };
28 |
--------------------------------------------------------------------------------
/react-query/back/models/hashtag.js:
--------------------------------------------------------------------------------
1 | const DataTypes = require('sequelize');
2 | const { Model } = DataTypes;
3 |
4 | module.exports = class Hashtag extends Model {
5 | static init(sequelize) {
6 | return super.init({
7 | // id가 기본적으로 들어있다.
8 | name: {
9 | type: DataTypes.STRING(20),
10 | allowNull: false,
11 | },
12 | }, {
13 | modelName: 'Hashtag',
14 | tableName: 'hashtags',
15 | charset: 'utf8mb4',
16 | collate: 'utf8mb4_general_ci', // 이모티콘 저장
17 | sequelize,
18 | });
19 | }
20 | static associate(db) {
21 | db.Hashtag.belongsToMany(db.Post, { through: 'PostHashtag' });
22 | }
23 | };
24 |
--------------------------------------------------------------------------------
/react-query/back/models/image.js:
--------------------------------------------------------------------------------
1 | const DataTypes = require('sequelize');
2 | const { Model } = DataTypes;
3 |
4 | module.exports = class Image extends Model {
5 | static init(sequelize) {
6 | return super.init({
7 | // id가 기본적으로 들어있다.
8 | src: {
9 | type: DataTypes.STRING(200),
10 | allowNull: false,
11 | },
12 | }, {
13 | modelName: 'Image',
14 | tableName: 'images',
15 | charset: 'utf8',
16 | collate: 'utf8_general_ci',
17 | sequelize,
18 | });
19 | }
20 | static associate(db) {
21 | db.Image.belongsTo(db.Post);
22 | }
23 | };
24 |
--------------------------------------------------------------------------------
/react-query/back/models/index.js:
--------------------------------------------------------------------------------
1 | const Sequelize = require('sequelize');
2 | const comment = require('./comment');
3 | const hashtag = require('./hashtag');
4 | const image = require('./image');
5 | const post = require('./post');
6 | const user = require('./user');
7 |
8 | const env = process.env.NODE_ENV || 'development';
9 | const config = require('../config/config')[env];
10 | const db = {};
11 |
12 | const sequelize = new Sequelize(config.database, config.username, config.password, config);
13 |
14 | db.Comment = comment;
15 | db.Hashtag = hashtag;
16 | db.Image = image;
17 | db.Post = post;
18 | db.User = user;
19 |
20 | Object.keys(db).forEach(modelName => {
21 | db[modelName].init(sequelize);
22 | })
23 |
24 | Object.keys(db).forEach(modelName => {
25 | if (db[modelName].associate) {
26 | db[modelName].associate(db);
27 | }
28 | });
29 |
30 | db.sequelize = sequelize;
31 | db.Sequelize = Sequelize;
32 |
33 | module.exports = db;
34 |
--------------------------------------------------------------------------------
/react-query/back/models/post.js:
--------------------------------------------------------------------------------
1 | const DataTypes = require('sequelize');
2 | const { Model } = DataTypes;
3 |
4 | module.exports = class Post extends Model {
5 | static init(sequelize) {
6 | return super.init({
7 | // id가 기본적으로 들어있다.
8 | content: {
9 | type: DataTypes.TEXT,
10 | allowNull: false,
11 | },
12 | // RetweetId
13 | }, {
14 | modelName: 'Post',
15 | tableName: 'posts',
16 | charset: 'utf8mb4',
17 | collate: 'utf8mb4_general_ci', // 이모티콘 저장
18 | sequelize,
19 | });
20 | }
21 | static associate(db) {
22 | db.Post.belongsTo(db.User); // post.addUser, post.getUser, post.setUser
23 | db.Post.belongsToMany(db.Hashtag, { through: 'PostHashtag' }); // post.addHashtags
24 | db.Post.hasMany(db.Comment); // post.addComments, post.getComments
25 | db.Post.hasMany(db.Image); // post.addImages, post.getImages
26 | db.Post.belongsToMany(db.User, { through: 'Like', as: 'Likers' }) // post.addLikers, post.removeLikers
27 | db.Post.belongsTo(db.Post, { as: 'Retweet' }); // post.addRetweet
28 | }
29 | };
30 |
--------------------------------------------------------------------------------
/react-query/back/models/user.js:
--------------------------------------------------------------------------------
1 | const DataTypes = require('sequelize');
2 | const { Model } = DataTypes;
3 |
4 | module.exports = class User extends Model {
5 | static init(sequelize) {
6 | return super.init({
7 | // id가 기본적으로 들어있다.
8 | email: {
9 | type: DataTypes.STRING(30), // STRING, TEXT, BOOLEAN, INTEGER, FLOAT, DATETIME
10 | allowNull: false, // 필수
11 | unique: true, // 고유한 값
12 | },
13 | nickname: {
14 | type: DataTypes.STRING(30),
15 | allowNull: false, // 필수
16 | },
17 | password: {
18 | type: DataTypes.STRING(100),
19 | allowNull: false, // 필수
20 | },
21 | }, {
22 | modelName: 'User',
23 | tableName: 'users',
24 | charset: 'utf8',
25 | collate: 'utf8_general_ci', // 한글 저장
26 | sequelize,
27 | });
28 | }
29 | static associate(db) {
30 | db.User.hasMany(db.Post);
31 | db.User.hasMany(db.Comment);
32 | db.User.belongsToMany(db.Post, { through: 'Like', as: 'Liked' })
33 | db.User.belongsToMany(db.User, { through: 'Follow', as: 'Followers', foreignKey: 'FollowingId' });
34 | db.User.belongsToMany(db.User, { through: 'Follow', as: 'Followings', foreignKey: 'FollowerId' });
35 | }
36 | };
37 |
--------------------------------------------------------------------------------
/react-query/back/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-nodebird-back",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "app.js",
6 | "scripts": {
7 | "dev": "nodemon app",
8 | "start": "cross-env NODE_ENV=production pm2 start app.js"
9 | },
10 | "author": "ZeroCho",
11 | "license": "ISC",
12 | "dependencies": {
13 | "aws-sdk": "^2.710.0",
14 | "bcrypt": "^5.0.0",
15 | "cookie-parser": "^1.4.5",
16 | "cors": "^2.8.5",
17 | "cross-env": "^7.0.2",
18 | "dotenv": "^10.0.0",
19 | "express": "^4.17.1",
20 | "express-session": "^1.17.1",
21 | "helmet": "^4.6.0",
22 | "hpp": "^0.2.3",
23 | "morgan": "^1.10.0",
24 | "multer": "^1.4.2",
25 | "multer-s3": "^2.9.0",
26 | "mysql2": "^2.1.0",
27 | "passport": "^0.4.1",
28 | "passport-local": "^1.0.0",
29 | "pm2": "^5.1.1",
30 | "sequelize": "^6.6.5",
31 | "sequelize-cli": "^6.2.0"
32 | },
33 | "devDependencies": {
34 | "nodemon": "^2.0.4"
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/react-query/back/passport/index.js:
--------------------------------------------------------------------------------
1 | const passport = require('passport');
2 | const local = require('./local');
3 | const { User } = require('../models');
4 |
5 | module.exports = () => {
6 | passport.serializeUser((user, done) => {
7 | done(null, user.id);
8 | });
9 |
10 | passport.deserializeUser(async (id, done) => {
11 | try {
12 | const user = await User.findOne({ where: { id }});
13 | done(null, user); // req.user
14 | } catch (error) {
15 | console.error(error);
16 | done(error);
17 | }
18 | });
19 |
20 | local();
21 | };
22 |
--------------------------------------------------------------------------------
/react-query/back/passport/local.js:
--------------------------------------------------------------------------------
1 | const passport = require('passport');
2 | const { Strategy: LocalStrategy } = require('passport-local');
3 | const bcrypt = require('bcrypt');
4 | const { User } = require('../models');
5 |
6 | module.exports = () => {
7 | passport.use(new LocalStrategy({
8 | usernameField: 'email',
9 | passwordField: 'password',
10 | }, async (email, password, done) => {
11 | try {
12 | const user = await User.findOne({
13 | where: { email }
14 | });
15 | if (!user) {
16 | return done(null, false, { reason: '존재하지 않는 이메일입니다!' });
17 | }
18 | const result = await bcrypt.compare(password, user.password);
19 | if (result) {
20 | return done(null, user);
21 | }
22 | return done(null, false, { reason: '비밀번호가 틀렸습니다.' });
23 | } catch (error) {
24 | console.error(error);
25 | return done(error);
26 | }
27 | }));
28 | };
29 |
--------------------------------------------------------------------------------
/react-query/back/routes/middlewares.js:
--------------------------------------------------------------------------------
1 | exports.isLoggedIn = (req, res, next) => {
2 | if (req.isAuthenticated()) {
3 | next();
4 | } else {
5 | res.status(401).send('로그인이 필요합니다.');
6 | }
7 | };
8 |
9 | exports.isNotLoggedIn = (req, res, next) => {
10 | if (!req.isAuthenticated()) {
11 | next();
12 | } else {
13 | res.status(401).send('로그인하지 않은 사용자만 접근 가능합니다.');
14 | }
15 | };
16 |
--------------------------------------------------------------------------------
/react-query/front/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["next/babel"],
3 | "plugins": [
4 | ["styled-components", {
5 | "ssr": true,
6 | "displayName": true
7 | }]
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------
/react-query/front/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "@typescript-eslint/parser",
3 | "parserOptions": {
4 | "ecmaVersion": 2020,
5 | "sourceType": "module",
6 | "ecmaFeatures": {
7 | "jsx": true
8 | }
9 | },
10 | "env": {
11 | "browser": true,
12 | "node": true,
13 | "es6": true
14 | },
15 | "extends": [
16 | "eslint:recommended",
17 | "plugin:prettier/recommended",
18 | "plugin:react-hooks/recommended"
19 | ],
20 | "plugins": [
21 | "@typescript-eslint",
22 | "import",
23 | "jsx-a11y"
24 | ],
25 | "rules": {
26 | "jsx-a11y/label-has-associated-control": "off",
27 | "jsx-a11y/anchor-is-valid": "off",
28 | "no-console": "off",
29 | "no-underscore-dangle": "off",
30 | "react/forbid-prop-types": "off",
31 | "react/jsx-filename-extension": "off",
32 | "react/jsx-one-expression-per-line": "off",
33 | "object-curly-newline": "off",
34 | "linebreak-style": "off",
35 | "no-param-reassign": "off"
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/react-query/front/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 120,
3 | "tabWidth": 2,
4 | "singleQuote": true,
5 | "trailingComma": "all",
6 | "semi": true
7 | }
8 |
--------------------------------------------------------------------------------
/react-query/front/components/FollowButton.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useState, VFC } from 'react';
2 | import { Button } from 'antd';
3 | import { useQuery } from 'react-query';
4 |
5 | import { followAPI, loadMyInfoAPI, unfollowAPI } from '../apis/user';
6 | import Post from '../interfaces/post';
7 | import User from '../interfaces/user';
8 |
9 | const FollowButton: VFC<{ post: Post }> = ({ post }) => {
10 | const { data: me } = useQuery('user', loadMyInfoAPI);
11 | const [loading, setLoading] = useState(false);
12 | const isFollowing = me?.Followings?.find((v) => v.id === post.User.id);
13 | const onClickButton = useCallback(() => {
14 | setLoading(true);
15 | if (isFollowing) {
16 | unfollowAPI(post.User.id).finally(() => {
17 | setLoading(false);
18 | });
19 | } else {
20 | followAPI(post.User.id).finally(() => {
21 | setLoading(false);
22 | });
23 | }
24 | }, [post.User.id, isFollowing]);
25 |
26 | if (post.User.id === me?.id) {
27 | return null;
28 | }
29 | return (
30 |
33 | );
34 | };
35 |
36 | export default FollowButton;
37 |
--------------------------------------------------------------------------------
/react-query/front/components/NicknameEditForm.tsx:
--------------------------------------------------------------------------------
1 | import { Form, Input } from 'antd';
2 | import React, { useCallback } from 'react';
3 | import { useQuery } from 'react-query';
4 |
5 | import { changeNicknameAPI, loadMyInfoAPI } from '../apis/user';
6 | import useInput from '../hooks/useInput';
7 | import User from '../interfaces/user';
8 |
9 | const NicknameEditForm = () => {
10 | const { data: me } = useQuery('user', loadMyInfoAPI);
11 | const [nickname, onChangeNickname] = useInput(me?.nickname || '');
12 |
13 | const onSubmit = useCallback(() => {
14 | changeNicknameAPI(nickname);
15 | }, [nickname]);
16 |
17 | return (
18 |
27 | );
28 | };
29 |
30 | export default NicknameEditForm;
31 |
--------------------------------------------------------------------------------
/react-query/front/config/config.ts:
--------------------------------------------------------------------------------
1 | export const backUrl = process.env.NODE_ENV === 'production' ? 'http://api.nodebird.com' : 'http://localhost:3065';
2 |
--------------------------------------------------------------------------------
/react-query/front/hooks/useInput.ts:
--------------------------------------------------------------------------------
1 | import { useState, useCallback, SetStateAction, Dispatch } from 'react';
2 |
3 | type ReturnType = [T, (e: any) => void, Dispatch>];
4 | export default (initialValue: T): ReturnType => {
5 | const [value, setValue] = useState(initialValue);
6 | const handler = useCallback((e) => {
7 | setValue(e.target.value);
8 | }, []);
9 | return [value, handler, setValue];
10 | };
11 |
--------------------------------------------------------------------------------
/react-query/front/interfaces/comment.ts:
--------------------------------------------------------------------------------
1 | import User from './user';
2 |
3 | export default interface Comment {
4 | id: number;
5 | content: string;
6 | createdAt: string;
7 | User: Partial;
8 | }
9 |
--------------------------------------------------------------------------------
/react-query/front/interfaces/post.ts:
--------------------------------------------------------------------------------
1 | import User from './user';
2 | import Comment from './comment';
3 |
4 | export default interface Post {
5 | id: number;
6 | content: string;
7 | Likers: Partial[];
8 | Images: Array<{ src: string }>;
9 | RetweetId?: number;
10 | Retweet?: Post;
11 | User: Partial & { id: number };
12 | createdAt: string;
13 | Comments: Comment[];
14 | }
15 |
--------------------------------------------------------------------------------
/react-query/front/interfaces/user.ts:
--------------------------------------------------------------------------------
1 | import Post from './post';
2 |
3 | export default interface User {
4 | id: number;
5 | email: string;
6 | nickname: string;
7 | password: string;
8 | Followings: User[];
9 | Followers: User[];
10 | Posts: Post[];
11 | }
12 |
--------------------------------------------------------------------------------
/react-query/front/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 | ///
4 |
5 | // NOTE: This file should not be edited
6 | // see https://nextjs.org/docs/basic-features/typescript for more information.
7 |
--------------------------------------------------------------------------------
/react-query/front/next.config.js:
--------------------------------------------------------------------------------
1 | const withBundleAnalyzer = require('@next/bundle-analyzer')({
2 | enabled: process.env.ANALYZE === 'true',
3 | });
4 |
5 | module.exports = withBundleAnalyzer({
6 | compress: true,
7 | webpack(config, { webpack }) {
8 | const prod = process.env.NODE_ENV === 'production';
9 | return {
10 | ...config,
11 | mode: prod ? 'production' : 'development',
12 | devtool: prod ? 'hidden-source-map' : 'eval',
13 | plugins: [...config.plugins, new webpack.ContextReplacementPlugin(/moment[/\\]locale$/, /^\.\/ko$/)],
14 | };
15 | },
16 | });
17 |
--------------------------------------------------------------------------------
/react-query/front/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import { AppProps, NextWebVitalsMetric } from 'next/app';
2 | import React, { useRef } from 'react';
3 | import PropTypes from 'prop-types';
4 | import Head from 'next/head';
5 | import 'antd/dist/antd.css';
6 | import { QueryClient, QueryClientProvider, Hydrate } from 'react-query';
7 | import { ReactQueryDevtools } from 'react-query/devtools';
8 |
9 | const NodeBird = ({ Component, pageProps }: AppProps) => {
10 | const queryClientRef = useRef();
11 | if (!queryClientRef.current) {
12 | queryClientRef.current = new QueryClient();
13 | }
14 |
15 | return (
16 |
17 |
18 |
19 |
20 | NodeBird
21 |
22 |
23 |
24 |
25 |
26 | );
27 | };
28 |
29 | NodeBird.propTypes = {
30 | Component: PropTypes.elementType.isRequired,
31 | };
32 |
33 | export function reportWebVitals(metric: NextWebVitalsMetric) {
34 | console.log(metric);
35 | }
36 |
37 | export default NodeBird;
38 |
--------------------------------------------------------------------------------
/react-query/front/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Document, { DocumentContext } from 'next/document';
3 | import { ServerStyleSheet } from 'styled-components';
4 |
5 | export default // @ts-ignore
6 | class MyDocument extends Document {
7 | static async getInitialProps(ctx: DocumentContext) {
8 | const sheet = new ServerStyleSheet();
9 | const originalRenderPage = ctx.renderPage;
10 | try {
11 | ctx.renderPage = () =>
12 | originalRenderPage({
13 | enhanceApp: (App) => (props) => sheet.collectStyles(),
14 | });
15 | const initialProps = await Document.getInitialProps(ctx);
16 | return {
17 | ...initialProps,
18 | styles: (
19 | <>
20 | {initialProps.styles}
21 | {sheet.getStyleElement()}
22 | >
23 | ),
24 | };
25 | } catch (error) {
26 | console.error(error);
27 | } finally {
28 | sheet.seal();
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/react-query/front/pages/error/notFound.tsx:
--------------------------------------------------------------------------------
1 | const NotFound = () => {
2 | return 존재하지 않는 페이지입니다.
;
3 | };
4 |
5 | export default NotFound;
6 |
--------------------------------------------------------------------------------
/react-query/front/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ZeroCho/react-nodebird/54bc0260fa5b78c9b7a460ca8832b485cdd2ec82/react-query/front/public/favicon.ico
--------------------------------------------------------------------------------
/react-query/front/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "strict": true,
12 | "forceConsistentCasingInFileNames": true,
13 | "noEmit": true,
14 | "esModuleInterop": true,
15 | "module": "esnext",
16 | "moduleResolution": "node",
17 | "resolveJsonModule": true,
18 | "isolatedModules": true,
19 | "jsx": "preserve"
20 | },
21 | "include": [
22 | "next-env.d.ts",
23 | "**/*.ts",
24 | "**/*.tsx"
25 | ],
26 | "exclude": [
27 | "node_modules"
28 | ]
29 | }
30 |
--------------------------------------------------------------------------------
/react-query/front/utils/produce.ts:
--------------------------------------------------------------------------------
1 | import { enableES5, produce } from 'immer';
2 |
3 | export default (...args: [any]) => {
4 | enableES5();
5 | return produce(...args);
6 | };
7 |
--------------------------------------------------------------------------------
/react-query/lambda/index.js:
--------------------------------------------------------------------------------
1 | const AWS = require('aws-sdk');
2 | const sharp = require('sharp');
3 |
4 | const s3 = new AWS.S3();
5 |
6 | exports.handler = async (event, context, callback) => {
7 | const Bucket = event.Records[0].s3.bucket.name; // react-nodebird-s3
8 | const Key = decodeURIComponent(event.Records[0].s3.object.key); // original/12312312_abc.png
9 | console.log(Bucket, Key);
10 | const filename = Key.split('/')[Key.split('/').length - 1];
11 | const ext = Key.split('.')[Key.split('.').length - 1].toLowerCase();
12 | const requiredFormat = ext === 'jpg' ? 'jpeg' : ext;
13 | console.log('filename', filename, 'ext', ext);
14 |
15 | try {
16 | const s3Object = await s3.getObject({ Bucket, Key }).promise();
17 | console.log('original', s3Object.Body.length);
18 | const resizedImage = await sharp(s3Object.Body)
19 | .resize(400, 400, { fit: 'inside' })
20 | .toFormat(requiredFormat)
21 | .toBuffer();
22 | await s3.putObject({
23 | Bucket,
24 | Key: `thumb/${filename}`,
25 | Body: resizedImage,
26 | }).promise();
27 | console.log('put', resizedImage.length);
28 | return callback(null, `thumb/${filename}`);
29 | } catch (error) {
30 | console.error(error)
31 | return callback(error);
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/react-query/lambda/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "lambda",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1"
8 | },
9 | "author": "ZeroCho",
10 | "license": "ISC",
11 | "dependencies": {
12 | "aws-sdk": "^2.710.0",
13 | "sharp": "^0.25.4"
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/react-query/react-nodebird-admin/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | npm-debug.log
3 | .env
4 |
--------------------------------------------------------------------------------
/react-query/react-nodebird-admin/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | .env
4 |
--------------------------------------------------------------------------------
/react-query/react-nodebird-admin/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:10-alpine
2 | WORKDIR /usr/src/app
3 | COPY package*.json ./
4 | RUN npm install lumber-cli -g -s
5 | RUN npm install -s
6 | COPY . .
7 | EXPOSE ${APPLICATION_PORT}
8 | CMD ["npm", "start"]
9 |
--------------------------------------------------------------------------------
/react-query/react-nodebird-admin/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.4'
2 | services:
3 | app:
4 | build:
5 | context: .
6 | dockerfile: Dockerfile
7 | container_name: react_nodebird_admin
8 | environment:
9 | - APPLICATION_PORT=${APPLICATION_PORT}
10 | - DATABASE_URL=${DOCKER_DATABASE_URL}
11 | - DATABASE_SSL=${DATABASE_SSL}
12 | - FOREST_AUTH_SECRET=${FOREST_AUTH_SECRET}
13 | - FOREST_ENV_SECRET=${FOREST_ENV_SECRET}
14 | ports:
15 | - "${APPLICATION_PORT}:${APPLICATION_PORT}"
16 | volumes:
17 | - ./:/usr/src/app
18 |
--------------------------------------------------------------------------------
/react-query/react-nodebird-admin/forest/comments.js:
--------------------------------------------------------------------------------
1 | const { collection } = require('forest-express-sequelize');
2 |
3 | // This file allows you to add to your Forest UI:
4 | // - Smart actions: https://docs.forestadmin.com/documentation/reference-guide/actions/create-and-manage-smart-actions
5 | // - Smart fields: https://docs.forestadmin.com/documentation/reference-guide/fields/create-and-manage-smart-fields
6 | // - Smart relationships: https://docs.forestadmin.com/documentation/reference-guide/relationships/create-a-smart-relationship
7 | // - Smart segments: https://docs.forestadmin.com/documentation/reference-guide/segments/smart-segments
8 | collection('comments', {
9 | actions: [],
10 | fields: [],
11 | segments: [],
12 | });
13 |
--------------------------------------------------------------------------------
/react-query/react-nodebird-admin/forest/follow.js:
--------------------------------------------------------------------------------
1 | const { collection } = require('forest-express-sequelize');
2 |
3 | // This file allows you to add to your Forest UI:
4 | // - Smart actions: https://docs.forestadmin.com/documentation/reference-guide/actions/create-and-manage-smart-actions
5 | // - Smart fields: https://docs.forestadmin.com/documentation/reference-guide/fields/create-and-manage-smart-fields
6 | // - Smart relationships: https://docs.forestadmin.com/documentation/reference-guide/relationships/create-a-smart-relationship
7 | // - Smart segments: https://docs.forestadmin.com/documentation/reference-guide/segments/smart-segments
8 | collection('follow', {
9 | actions: [],
10 | fields: [],
11 | segments: [],
12 | });
13 |
--------------------------------------------------------------------------------
/react-query/react-nodebird-admin/forest/hashtags.js:
--------------------------------------------------------------------------------
1 | const { collection } = require('forest-express-sequelize');
2 |
3 | // This file allows you to add to your Forest UI:
4 | // - Smart actions: https://docs.forestadmin.com/documentation/reference-guide/actions/create-and-manage-smart-actions
5 | // - Smart fields: https://docs.forestadmin.com/documentation/reference-guide/fields/create-and-manage-smart-fields
6 | // - Smart relationships: https://docs.forestadmin.com/documentation/reference-guide/relationships/create-a-smart-relationship
7 | // - Smart segments: https://docs.forestadmin.com/documentation/reference-guide/segments/smart-segments
8 | collection('hashtags', {
9 | actions: [],
10 | fields: [],
11 | segments: [],
12 | });
13 |
--------------------------------------------------------------------------------
/react-query/react-nodebird-admin/forest/images.js:
--------------------------------------------------------------------------------
1 | const { collection } = require('forest-express-sequelize');
2 |
3 | // This file allows you to add to your Forest UI:
4 | // - Smart actions: https://docs.forestadmin.com/documentation/reference-guide/actions/create-and-manage-smart-actions
5 | // - Smart fields: https://docs.forestadmin.com/documentation/reference-guide/fields/create-and-manage-smart-fields
6 | // - Smart relationships: https://docs.forestadmin.com/documentation/reference-guide/relationships/create-a-smart-relationship
7 | // - Smart segments: https://docs.forestadmin.com/documentation/reference-guide/segments/smart-segments
8 | collection('images', {
9 | actions: [],
10 | fields: [],
11 | segments: [],
12 | });
13 |
--------------------------------------------------------------------------------
/react-query/react-nodebird-admin/forest/like.js:
--------------------------------------------------------------------------------
1 | const { collection } = require('forest-express-sequelize');
2 |
3 | // This file allows you to add to your Forest UI:
4 | // - Smart actions: https://docs.forestadmin.com/documentation/reference-guide/actions/create-and-manage-smart-actions
5 | // - Smart fields: https://docs.forestadmin.com/documentation/reference-guide/fields/create-and-manage-smart-fields
6 | // - Smart relationships: https://docs.forestadmin.com/documentation/reference-guide/relationships/create-a-smart-relationship
7 | // - Smart segments: https://docs.forestadmin.com/documentation/reference-guide/segments/smart-segments
8 | collection('like', {
9 | actions: [],
10 | fields: [],
11 | segments: [],
12 | });
13 |
--------------------------------------------------------------------------------
/react-query/react-nodebird-admin/forest/post-hashtag.js:
--------------------------------------------------------------------------------
1 | const { collection } = require('forest-express-sequelize');
2 |
3 | // This file allows you to add to your Forest UI:
4 | // - Smart actions: https://docs.forestadmin.com/documentation/reference-guide/actions/create-and-manage-smart-actions
5 | // - Smart fields: https://docs.forestadmin.com/documentation/reference-guide/fields/create-and-manage-smart-fields
6 | // - Smart relationships: https://docs.forestadmin.com/documentation/reference-guide/relationships/create-a-smart-relationship
7 | // - Smart segments: https://docs.forestadmin.com/documentation/reference-guide/segments/smart-segments
8 | collection('postHashtag', {
9 | actions: [],
10 | fields: [],
11 | segments: [],
12 | });
13 |
--------------------------------------------------------------------------------
/react-query/react-nodebird-admin/forest/posts.js:
--------------------------------------------------------------------------------
1 | const { collection } = require('forest-express-sequelize');
2 |
3 | // This file allows you to add to your Forest UI:
4 | // - Smart actions: https://docs.forestadmin.com/documentation/reference-guide/actions/create-and-manage-smart-actions
5 | // - Smart fields: https://docs.forestadmin.com/documentation/reference-guide/fields/create-and-manage-smart-fields
6 | // - Smart relationships: https://docs.forestadmin.com/documentation/reference-guide/relationships/create-a-smart-relationship
7 | // - Smart segments: https://docs.forestadmin.com/documentation/reference-guide/segments/smart-segments
8 | collection('posts', {
9 | actions: [],
10 | fields: [],
11 | segments: [],
12 | });
13 |
--------------------------------------------------------------------------------
/react-query/react-nodebird-admin/forest/retweet.js:
--------------------------------------------------------------------------------
1 | const { collection } = require('forest-express-sequelize');
2 |
3 | // This file allows you to add to your Forest UI:
4 | // - Smart actions: https://docs.forestadmin.com/documentation/reference-guide/actions/create-and-manage-smart-actions
5 | // - Smart fields: https://docs.forestadmin.com/documentation/reference-guide/fields/create-and-manage-smart-fields
6 | // - Smart relationships: https://docs.forestadmin.com/documentation/reference-guide/relationships/create-a-smart-relationship
7 | // - Smart segments: https://docs.forestadmin.com/documentation/reference-guide/segments/smart-segments
8 | collection('retweet', {
9 | actions: [],
10 | fields: [],
11 | segments: [],
12 | });
13 |
--------------------------------------------------------------------------------
/react-query/react-nodebird-admin/forest/users.js:
--------------------------------------------------------------------------------
1 | const { collection } = require('forest-express-sequelize');
2 |
3 | // This file allows you to add to your Forest UI:
4 | // - Smart actions: https://docs.forestadmin.com/documentation/reference-guide/actions/create-and-manage-smart-actions
5 | // - Smart fields: https://docs.forestadmin.com/documentation/reference-guide/fields/create-and-manage-smart-fields
6 | // - Smart relationships: https://docs.forestadmin.com/documentation/reference-guide/relationships/create-a-smart-relationship
7 | // - Smart segments: https://docs.forestadmin.com/documentation/reference-guide/segments/smart-segments
8 | collection('users', {
9 | actions: [],
10 | fields: [],
11 | segments: [],
12 | });
13 |
--------------------------------------------------------------------------------
/react-query/react-nodebird-admin/middlewares/forestadmin.js:
--------------------------------------------------------------------------------
1 | const chalk = require('chalk');
2 | const path = require('path');
3 | const Liana = require('forest-express-sequelize');
4 | const { sequelize } = require('../models');
5 |
6 | module.exports = async function forestadmin(app) {
7 | app.use(await Liana.init({
8 | modelsDir: path.join(__dirname, '../models'),
9 | configDir: path.join(__dirname, '../forest'),
10 | envSecret: process.env.FOREST_ENV_SECRET,
11 | authSecret: process.env.FOREST_AUTH_SECRET,
12 | sequelize,
13 | }));
14 |
15 | console.log(chalk.cyan('Your admin panel is available here: https://app.forestadmin.com/projects'));
16 | };
17 |
--------------------------------------------------------------------------------
/react-query/react-nodebird-admin/middlewares/welcome.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | module.exports = function welcome(app) {
4 | app.get('/', (req, res) => {
5 | res.sendFile(path.join(__dirname, '../views/index.html'));
6 | });
7 | };
8 |
--------------------------------------------------------------------------------
/react-query/react-nodebird-admin/models/follow.js:
--------------------------------------------------------------------------------
1 | // This model was generated by Lumber. However, you remain in control of your models.
2 | // Learn how here: https://docs.forestadmin.com/documentation/v/v6/reference-guide/models/enrich-your-models
3 | module.exports = (sequelize, DataTypes) => {
4 | const { Sequelize } = sequelize;
5 | // This section contains the fields of your model, mapped to your table's columns.
6 | // Learn more here: https://docs.forestadmin.com/documentation/v/v6/reference-guide/models/enrich-your-models#declaring-a-new-field-in-a-model
7 | const Follow = sequelize.define('follow', {
8 | createdAt: {
9 | type: DataTypes.DATE,
10 | },
11 | updatedAt: {
12 | type: DataTypes.DATE,
13 | },
14 | followingId: {
15 | type: DataTypes.INTEGER,
16 | primaryKey: true,
17 | allowNull: false,
18 | },
19 | followerId: {
20 | type: DataTypes.INTEGER,
21 | primaryKey: true,
22 | allowNull: false,
23 | },
24 | }, {
25 | tableName: 'Follow',
26 | });
27 |
28 | // This section contains the relationships for this model. See: https://docs.forestadmin.com/documentation/v/v6/reference-guide/relationships#adding-relationships.
29 | Follow.associate = (models) => {
30 | };
31 |
32 | return Follow;
33 | };
34 |
--------------------------------------------------------------------------------
/react-query/react-nodebird-admin/models/hashtags.js:
--------------------------------------------------------------------------------
1 | // This model was generated by Lumber. However, you remain in control of your models.
2 | // Learn how here: https://docs.forestadmin.com/documentation/v/v6/reference-guide/models/enrich-your-models
3 | module.exports = (sequelize, DataTypes) => {
4 | const { Sequelize } = sequelize;
5 | // This section contains the fields of your model, mapped to your table's columns.
6 | // Learn more here: https://docs.forestadmin.com/documentation/v/v6/reference-guide/models/enrich-your-models#declaring-a-new-field-in-a-model
7 | const Hashtags = sequelize.define('hashtags', {
8 | name: {
9 | type: DataTypes.STRING,
10 | allowNull: false,
11 | },
12 | createdAt: {
13 | type: DataTypes.DATE,
14 | },
15 | updatedAt: {
16 | type: DataTypes.DATE,
17 | },
18 | }, {
19 | tableName: 'Hashtags',
20 | });
21 |
22 | // This section contains the relationships for this model. See: https://docs.forestadmin.com/documentation/v/v6/reference-guide/relationships#adding-relationships.
23 | Hashtags.associate = (models) => {
24 | };
25 |
26 | return Hashtags;
27 | };
28 |
--------------------------------------------------------------------------------
/react-query/react-nodebird-admin/models/images.js:
--------------------------------------------------------------------------------
1 | // This model was generated by Lumber. However, you remain in control of your models.
2 | // Learn how here: https://docs.forestadmin.com/documentation/v/v6/reference-guide/models/enrich-your-models
3 | module.exports = (sequelize, DataTypes) => {
4 | const { Sequelize } = sequelize;
5 | // This section contains the fields of your model, mapped to your table's columns.
6 | // Learn more here: https://docs.forestadmin.com/documentation/v/v6/reference-guide/models/enrich-your-models#declaring-a-new-field-in-a-model
7 | const Images = sequelize.define('images', {
8 | src: {
9 | type: DataTypes.STRING,
10 | allowNull: false,
11 | },
12 | createdAt: {
13 | type: DataTypes.DATE,
14 | },
15 | updatedAt: {
16 | type: DataTypes.DATE,
17 | },
18 | }, {
19 | tableName: 'Images',
20 | });
21 |
22 | // This section contains the relationships for this model. See: https://docs.forestadmin.com/documentation/v/v6/reference-guide/relationships#adding-relationships.
23 | Images.associate = (models) => {
24 | Images.belongsTo(models.posts, {
25 | foreignKey: {
26 | name: 'postIdKey',
27 | field: 'PostId',
28 | },
29 | as: 'post',
30 | });
31 | };
32 |
33 | return Images;
34 | };
35 |
--------------------------------------------------------------------------------
/react-query/react-nodebird-admin/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-nodebird-admin",
3 | "version": "0.0.1",
4 | "private": true,
5 | "scripts": {
6 | "start": "node ./server.js"
7 | },
8 | "dependencies": {
9 | "body-parser": "1.19.0",
10 | "chalk": "~1.1.3",
11 | "cookie-parser": "1.4.4",
12 | "cors": "2.8.5",
13 | "debug": "~4.0.1",
14 | "dotenv": "~6.1.0",
15 | "express": "~4.16.3",
16 | "express-jwt": "8.4.1",
17 | "forest-express": "^10.1.10",
18 | "forest-express-sequelize": "^9.2.7",
19 | "morgan": "1.9.1",
20 | "require-all": "^3.0.0",
21 | "sequelize": "~6.29.0",
22 | "mysql2": "~1.7.0"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/toolkit/back/config/config.js:
--------------------------------------------------------------------------------
1 | const dotenv = require('dotenv');
2 |
3 | dotenv.config();
4 |
5 | module.exports = {
6 | development: {
7 | username: 'root',
8 | password: process.env.DB_PASSWORD,
9 | database: 'react-nodebird',
10 | host: '127.0.0.1',
11 | dialect: 'mysql',
12 | },
13 | test: {
14 | username: 'root',
15 | password: process.env.DB_PASSWORD,
16 | database: 'react-nodebird',
17 | host: '127.0.0.1',
18 | dialect: 'mysql',
19 | },
20 | production: {
21 | username: 'root',
22 | password: process.env.DB_PASSWORD,
23 | database: 'react-nodebird',
24 | host: '127.0.0.1',
25 | dialect: 'mysql',
26 | },
27 | };
28 |
--------------------------------------------------------------------------------
/toolkit/back/models/comment.js:
--------------------------------------------------------------------------------
1 | const DataTypes = require('sequelize');
2 | const { Model } = DataTypes;
3 |
4 | module.exports = class Comment extends Model {
5 | static init(sequelize) {
6 | return super.init({
7 | // id가 기본적으로 들어있다.
8 | content: {
9 | type: DataTypes.TEXT,
10 | allowNull: false,
11 | },
12 | // UserId: 1
13 | // PostId: 3
14 | }, {
15 | modelName: 'Comment',
16 | tableName: 'comments',
17 | charset: 'utf8mb4',
18 | collate: 'utf8mb4_general_ci', // 이모티콘 저장
19 | sequelize,
20 | });
21 | }
22 |
23 | static associate(db) {
24 | db.Comment.belongsTo(db.User);
25 | db.Comment.belongsTo(db.Post);
26 | }
27 | };
28 |
--------------------------------------------------------------------------------
/toolkit/back/models/hashtag.js:
--------------------------------------------------------------------------------
1 | const DataTypes = require('sequelize');
2 | const { Model } = DataTypes;
3 |
4 | module.exports = class Hashtag extends Model {
5 | static init(sequelize) {
6 | return super.init({
7 | // id가 기본적으로 들어있다.
8 | name: {
9 | type: DataTypes.STRING(20),
10 | allowNull: false,
11 | },
12 | }, {
13 | modelName: 'Hashtag',
14 | tableName: 'hashtags',
15 | charset: 'utf8mb4',
16 | collate: 'utf8mb4_general_ci', // 이모티콘 저장
17 | sequelize,
18 | });
19 | }
20 | static associate(db) {
21 | db.Hashtag.belongsToMany(db.Post, { through: 'PostHashtag' });
22 | }
23 | };
24 |
--------------------------------------------------------------------------------
/toolkit/back/models/image.js:
--------------------------------------------------------------------------------
1 | const DataTypes = require('sequelize');
2 | const { Model } = DataTypes;
3 |
4 | module.exports = class Image extends Model {
5 | static init(sequelize) {
6 | return super.init({
7 | // id가 기본적으로 들어있다.
8 | src: {
9 | type: DataTypes.STRING(200),
10 | allowNull: false,
11 | },
12 | }, {
13 | modelName: 'Image',
14 | tableName: 'images',
15 | charset: 'utf8',
16 | collate: 'utf8_general_ci',
17 | sequelize,
18 | });
19 | }
20 | static associate(db) {
21 | db.Image.belongsTo(db.Post);
22 | }
23 | };
24 |
--------------------------------------------------------------------------------
/toolkit/back/models/index.js:
--------------------------------------------------------------------------------
1 | const Sequelize = require('sequelize');
2 | const comment = require('./comment');
3 | const hashtag = require('./hashtag');
4 | const image = require('./image');
5 | const post = require('./post');
6 | const user = require('./user');
7 |
8 | const env = process.env.NODE_ENV || 'development';
9 | const config = require('../config/config')[env];
10 | const db = {};
11 |
12 | const sequelize = new Sequelize(config.database, config.username, config.password, config);
13 |
14 | db.Comment = comment;
15 | db.Hashtag = hashtag;
16 | db.Image = image;
17 | db.Post = post;
18 | db.User = user;
19 |
20 | Object.keys(db).forEach(modelName => {
21 | db[modelName].init(sequelize);
22 | })
23 |
24 | Object.keys(db).forEach(modelName => {
25 | if (db[modelName].associate) {
26 | db[modelName].associate(db);
27 | }
28 | });
29 |
30 | db.sequelize = sequelize;
31 | db.Sequelize = Sequelize;
32 |
33 | module.exports = db;
34 |
--------------------------------------------------------------------------------
/toolkit/back/models/post.js:
--------------------------------------------------------------------------------
1 | const DataTypes = require('sequelize');
2 | const { Model } = DataTypes;
3 |
4 | module.exports = class Post extends Model {
5 | static init(sequelize) {
6 | return super.init({
7 | // id가 기본적으로 들어있다.
8 | content: {
9 | type: DataTypes.TEXT,
10 | allowNull: false,
11 | },
12 | // RetweetId
13 | }, {
14 | modelName: 'Post',
15 | tableName: 'posts',
16 | charset: 'utf8mb4',
17 | collate: 'utf8mb4_general_ci', // 이모티콘 저장
18 | sequelize,
19 | });
20 | }
21 | static associate(db) {
22 | db.Post.belongsTo(db.User); // post.addUser, post.getUser, post.setUser
23 | db.Post.belongsToMany(db.Hashtag, { through: 'PostHashtag' }); // post.addHashtags
24 | db.Post.hasMany(db.Comment); // post.addComments, post.getComments
25 | db.Post.hasMany(db.Image); // post.addImages, post.getImages
26 | db.Post.belongsToMany(db.User, { through: 'Like', as: 'Likers' }) // post.addLikers, post.removeLikers
27 | db.Post.belongsTo(db.Post, { as: 'Retweet' }); // post.addRetweet
28 | }
29 | };
30 |
--------------------------------------------------------------------------------
/toolkit/back/models/user.js:
--------------------------------------------------------------------------------
1 | const DataTypes = require('sequelize');
2 | const { Model } = DataTypes;
3 |
4 | module.exports = class Post extends Model {
5 | static init(sequelize) {
6 | return super.init({
7 | // id가 기본적으로 들어있다.
8 | email: {
9 | type: DataTypes.STRING(30), // STRING, TEXT, BOOLEAN, INTEGER, FLOAT, DATETIME
10 | allowNull: false, // 필수
11 | unique: true, // 고유한 값
12 | },
13 | nickname: {
14 | type: DataTypes.STRING(30),
15 | allowNull: false, // 필수
16 | },
17 | password: {
18 | type: DataTypes.STRING(100),
19 | allowNull: false, // 필수
20 | },
21 | }, {
22 | modelName: 'User',
23 | tableName: 'users',
24 | charset: 'utf8',
25 | collate: 'utf8_general_ci', // 한글 저장
26 | sequelize,
27 | });
28 | }
29 | static associate(db) {
30 | db.User.hasMany(db.Post);
31 | db.User.hasMany(db.Comment);
32 | db.User.belongsToMany(db.Post, { through: 'Like', as: 'Liked' })
33 | db.User.belongsToMany(db.User, { through: 'Follow', as: 'Followers', foreignKey: 'FollowingId' });
34 | db.User.belongsToMany(db.User, { through: 'Follow', as: 'Followings', foreignKey: 'FollowerId' });
35 | }
36 | };
37 |
--------------------------------------------------------------------------------
/toolkit/back/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-nodebird-back",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "app.js",
6 | "scripts": {
7 | "dev": "nodemon app",
8 | "start": "cross-env NODE_ENV=production pm2 start app.js"
9 | },
10 | "author": "ZeroCho",
11 | "license": "ISC",
12 | "dependencies": {
13 | "aws-sdk": "^2.807.0",
14 | "bcrypt": "^5.0.0",
15 | "cookie-parser": "^1.4.5",
16 | "cors": "^2.8.5",
17 | "cross-env": "^7.0.3",
18 | "dotenv": "^16.3.1",
19 | "express": "^4.17.1",
20 | "express-session": "^1.17.1",
21 | "helmet": "^7.0.0",
22 | "hpp": "^0.2.3",
23 | "morgan": "^1.10.0",
24 | "multer": "^1.4.2",
25 | "multer-s3": "^2.9.0",
26 | "mysql2": "^3.6.0",
27 | "passport": "^0.6.0",
28 | "passport-local": "^1.0.0",
29 | "pm2": "^5.2.2",
30 | "sequelize": "^6.3.5",
31 | "sequelize-cli": "^6.2.0"
32 | },
33 | "devDependencies": {
34 | "nodemon": "^3.0.1"
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/toolkit/back/passport/index.js:
--------------------------------------------------------------------------------
1 | const passport = require('passport');
2 | const local = require('./local');
3 | const { User } = require('../models');
4 |
5 | module.exports = () => {
6 | passport.serializeUser((user, done) => {
7 | done(null, user.id);
8 | });
9 |
10 | passport.deserializeUser(async (id, done) => {
11 | try {
12 | const user = await User.findOne({ where: { id }});
13 | done(null, user); // req.user
14 | } catch (error) {
15 | console.error(error);
16 | done(error);
17 | }
18 | });
19 |
20 | local();
21 | };
22 |
--------------------------------------------------------------------------------
/toolkit/back/passport/local.js:
--------------------------------------------------------------------------------
1 | const passport = require('passport');
2 | const { Strategy: LocalStrategy } = require('passport-local');
3 | const bcrypt = require('bcrypt');
4 | const { User } = require('../models');
5 |
6 | module.exports = () => {
7 | passport.use(new LocalStrategy({
8 | usernameField: 'email',
9 | passwordField: 'password',
10 | }, async (email, password, done) => {
11 | try {
12 | const user = await User.findOne({
13 | where: { email }
14 | });
15 | if (!user) {
16 | return done(null, false, { reason: '존재하지 않는 이메일입니다!' });
17 | }
18 | const result = await bcrypt.compare(password, user.password);
19 | if (result) {
20 | return done(null, user);
21 | }
22 | return done(null, false, { reason: '비밀번호가 틀렸습니다.' });
23 | } catch (error) {
24 | console.error(error);
25 | return done(error);
26 | }
27 | }));
28 | };
29 |
--------------------------------------------------------------------------------
/toolkit/back/routes/middlewares.js:
--------------------------------------------------------------------------------
1 | exports.isLoggedIn = (req, res, next) => {
2 | if (req.isAuthenticated()) {
3 | next();
4 | } else {
5 | res.status(401).send('로그인이 필요합니다.');
6 | }
7 | };
8 |
9 | exports.isNotLoggedIn = (req, res, next) => {
10 | if (!req.isAuthenticated()) {
11 | next();
12 | } else {
13 | res.status(401).send('로그인하지 않은 사용자만 접근 가능합니다.');
14 | }
15 | };
16 |
--------------------------------------------------------------------------------
/toolkit/front/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "@babel/eslint-parser",
3 | "parserOptions": {
4 | "ecmaVersion": 2020,
5 | "sourceType": "module",
6 | "ecmaFeatures": {
7 | "jsx": true
8 | },
9 | "babelOptions": {
10 | "presets": ["next/babel"]
11 | },
12 | "requireConfigFile": false
13 | },
14 | "env": {
15 | "browser": true,
16 | "node": true,
17 | "es6": true
18 | },
19 | "extends": [
20 | "airbnb"
21 | ],
22 | "plugins": [
23 | "import",
24 | "react-hooks",
25 | "jsx-a11y"
26 | ],
27 | "rules": {
28 | "jsx-a11y/label-has-associated-control": "off",
29 | "jsx-a11y/anchor-is-valid": "off",
30 | "no-console": "off",
31 | "no-underscore-dangle": "off",
32 | "react/forbid-prop-types": "off",
33 | "react/jsx-filename-extension": "off",
34 | "react/jsx-one-expression-per-line": "off",
35 | "react/jsx-props-no-spreading": "off",
36 | "object-curly-newline": "off",
37 | "linebreak-style": "off",
38 | "no-param-reassign": "off",
39 | "max-len": "off"
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/toolkit/front/components/FollowButton.js:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from 'react';
2 | import { Button } from 'antd';
3 | import PropTypes from 'prop-types';
4 | import { useSelector, useDispatch } from 'react-redux';
5 | import { follow, unfollow } from '../reducers/user';
6 |
7 | const FollowButton = ({ post }) => {
8 | const dispatch = useDispatch();
9 | const { me, followLoading, unfollowLoading } = useSelector((state) => state.user);
10 | const isFollowing = me?.Followings.find((v) => v.id === post.User.id);
11 | const onClickButton = useCallback(() => {
12 | if (isFollowing) {
13 | dispatch(unfollow(post.User.id));
14 | } else {
15 | dispatch(follow(post.User.id));
16 | }
17 | }, [isFollowing]);
18 |
19 | if (post.User.id === me.id) {
20 | return null;
21 | }
22 | return (
23 |
26 | );
27 | };
28 |
29 | FollowButton.propTypes = {
30 | post: PropTypes.object.isRequired,
31 | };
32 |
33 | export default FollowButton;
34 |
--------------------------------------------------------------------------------
/toolkit/front/components/NicknameEditForm.js:
--------------------------------------------------------------------------------
1 | import { Form, Input } from 'antd';
2 | import React, { useCallback } from 'react';
3 | import { useSelector, useDispatch } from 'react-redux';
4 |
5 | import useInput from '../hooks/useInput';
6 | import { changeNickname } from '../reducers/user';
7 |
8 | const NicknameEditForm = () => {
9 | const { me } = useSelector((state) => state.user);
10 | const [nickname, onChangeNickname] = useInput(me?.nickname || '');
11 | const dispatch = useDispatch();
12 |
13 | const onSubmit = useCallback(() => {
14 | dispatch(changeNickname(nickname));
15 | }, [nickname]);
16 |
17 | return (
18 |
27 | );
28 | };
29 |
30 | export default NicknameEditForm;
31 |
--------------------------------------------------------------------------------
/toolkit/front/components/UserProfile.js:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from 'react';
2 | import { useDispatch, useSelector } from 'react-redux';
3 | import { Card, Avatar, Button } from 'antd';
4 | import Link from 'next/link';
5 |
6 | import { logout } from '../reducers/user';
7 |
8 | function UserProfile() {
9 | const dispatch = useDispatch();
10 | const { me, logOutLoading } = useSelector((state) => state.user);
11 |
12 | const onLogOut = useCallback(() => {
13 | dispatch(logout());
14 | }, []);
15 |
16 | return (
17 | 짹짹
{me.Posts.length},
20 | 팔로잉
{me.Followings.length}
,
21 | 팔로워
{me.Followers.length}
,
22 | ]}
23 | extra={}
24 | >
25 |
28 | {me.nickname[0]}
29 |
30 | )}
31 | title={me.nickname}
32 | />
33 |
34 | );
35 | }
36 |
37 | export default UserProfile;
38 |
--------------------------------------------------------------------------------
/toolkit/front/config/config.js:
--------------------------------------------------------------------------------
1 | export const backUrl = process.env.NODE_ENV === 'production'
2 | ? 'http://api.nodebird.com' : 'http://localhost:3065';
3 |
--------------------------------------------------------------------------------
/toolkit/front/hooks/useInput.js:
--------------------------------------------------------------------------------
1 | import { useState, useCallback } from 'react';
2 |
3 | export default (initialValue = null) => {
4 | const [value, setValue] = useState(initialValue);
5 | const handler = useCallback((e) => {
6 | setValue(e.target.value);
7 | }, []);
8 | return [value, handler, setValue];
9 | };
10 |
--------------------------------------------------------------------------------
/toolkit/front/next.config.js:
--------------------------------------------------------------------------------
1 | const withBundleAnalyzer = require('@next/bundle-analyzer')({
2 | enabled: process.env.ANALYZE === 'true',
3 | });
4 |
5 | module.exports = withBundleAnalyzer({
6 | images: {
7 | domains: ['react-nodebird.s3.ap-northeast-2.amazonaws.com', 'react-nodebird-s3.s3.amazonaws.com'],
8 | },
9 | compress: true,
10 | compiler: {
11 | styledComponents: {
12 | ssr: true,
13 | displayName: true,
14 | },
15 | },
16 | webpack(config, { webpack }) {
17 | const prod = process.env.NODE_ENV === 'production';
18 | return {
19 | ...config,
20 | mode: prod ? 'production' : 'development',
21 | devtool: prod ? 'hidden-source-map' : 'inline-source-map',
22 | plugins: [
23 | ...config.plugins,
24 | new webpack.ContextReplacementPlugin(/moment[/\\]locale$/, /^\.\/ko$/),
25 | ],
26 | };
27 | },
28 | });
29 |
--------------------------------------------------------------------------------
/toolkit/front/pages/_app.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import Head from 'next/head';
4 | import { Provider } from 'react-redux';
5 | import wrapper from '../store/configureStore';
6 | import 'slick-carousel/slick/slick.css';
7 | import 'slick-carousel/slick/slick-theme.css';
8 |
9 | function NodeBird({ Component, ...rest }) {
10 | const { store, props } = wrapper.useWrappedStore(rest);
11 | const { pageProps } = props;
12 | return (
13 |
14 |
15 |
16 |
20 |
21 | NodeBird
22 |
23 |
24 |
25 | );
26 | }
27 | NodeBird.propTypes = {
28 | Component: PropTypes.elementType.isRequired,
29 | pageProps: PropTypes.any.isRequired,
30 | };
31 |
32 | export function reportWebVitals(metric) {
33 | console.log(metric);
34 | }
35 |
36 | export default NodeBird;
37 |
--------------------------------------------------------------------------------
/toolkit/front/pages/_document.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Document, { Html, Head, Main, NextScript } from 'next/document';
3 | import { ServerStyleSheet } from 'styled-components';
4 |
5 | export default class MyDocument extends Document {
6 | static async getInitalProps(ctx) {
7 | const sheet = new ServerStyleSheet();
8 | const originalRenderPage = ctx.renderPage;
9 | try {
10 | ctx.renderPage = () => originalRenderPage({
11 | enhanceApp: (App) => (props) => sheet.collectStyles(),
12 | });
13 | const initialProps = await Document.getInitialProps(ctx);
14 | return {
15 | ...initialProps,
16 | styles: (
17 | <>
18 | {initialProps.styles}
19 | {sheet.getStyleElement()}
20 | >
21 | ),
22 | };
23 | } catch (error) {
24 | console.error(error);
25 | } finally {
26 | sheet.seal();
27 | }
28 | }
29 |
30 | render() {
31 | return (
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | );
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/toolkit/front/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ZeroCho/react-nodebird/54bc0260fa5b78c9b7a460ca8832b485cdd2ec82/toolkit/front/public/favicon.ico
--------------------------------------------------------------------------------
/toolkit/front/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 | import axios from 'axios';
3 |
4 | import userSlice from './user';
5 | import postSlice from './post';
6 |
7 | axios.defaults.baseURL = 'http://localhost:3065';
8 | axios.defaults.withCredentials = true;
9 | // (이전상태, 액션) => 다음상태
10 | const rootReducer = combineReducers({
11 | user: userSlice.reducer,
12 | post: postSlice.reducer,
13 | });
14 |
15 | export default rootReducer;
16 |
--------------------------------------------------------------------------------
/toolkit/front/store/configureStore.js:
--------------------------------------------------------------------------------
1 | import { configureStore } from '@reduxjs/toolkit';
2 | import { createWrapper } from 'next-redux-wrapper';
3 | import reducer from '../reducers';
4 |
5 | function getServerState() {
6 | return typeof document !== 'undefined'
7 | ? JSON.parse(document.querySelector('#__NEXT_DATA__').textContent)?.props.pageProps.initialState
8 | : undefined;
9 | }
10 | const serverState = getServerState();
11 | console.log('serverState', serverState);
12 | const makeStore = () => configureStore({
13 | reducer,
14 | devTools: true,
15 | middleware: (getDefaultMiddleware) => getDefaultMiddleware(),
16 | preloadedState: serverState, // SSR
17 | });
18 |
19 | export default createWrapper(makeStore, {
20 | debug: process.env.NODE_ENV !== 'production',
21 | });
22 |
--------------------------------------------------------------------------------