├── .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 | 10 | 15 | 16 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 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 | 6 | -------------------------------------------------------------------------------- /.idea/jsLibraryMappings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /.idea/jsLinters/eslint.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/markdown-exported-files.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/prettier.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 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 | 10 | 노드버드 11 | 프로필 12 | 13 | 14 | 15 | 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 |
18 | 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 |
18 |
19 | 20 |
21 | 22 |
23 |
24 | 25 |
26 | 27 |
28 |
29 | 30 | 31 |
32 |
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 |
7 | 8 | 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 |
18 | 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 |
7 | 8 | 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 | 15 | 노드버드 16 | 프로필 17 | 18 | 19 | 20 | 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 |
25 | 31 | 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 |
22 | 29 | 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 |
22 | 29 | 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 |
22 | 29 | 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 |
22 | 29 | 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 |
22 | 29 | 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 | 34 | 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 |
19 | 26 | 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 |
19 | 26 | 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 | --------------------------------------------------------------------------------