├── .github ├── ISSUE_TEMPLATE │ ├── -----.md │ └── new-feature.md └── PULL_REQUEST_TEMPLATE.md ├── README.md ├── be ├── .env.example ├── .eslintrc ├── .gitignore ├── .prettierrc ├── README.md ├── package-lock.json ├── package.json ├── src │ ├── app.ts │ ├── libs │ │ ├── hash-password.ts │ │ ├── jwt-token.ts │ │ └── utiltys.ts │ ├── models │ │ ├── index.ts │ │ ├── notification.ts │ │ ├── tweet.ts │ │ └── user.ts │ ├── providers │ │ └── dbProvider.ts │ ├── resolvers │ │ ├── file.ts │ │ ├── index.ts │ │ ├── notification.ts │ │ ├── tweet.ts │ │ └── user.ts │ ├── schema │ │ ├── common.gql │ │ ├── file.gql │ │ ├── index.ts │ │ ├── notification.gql │ │ ├── tweet.gql │ │ └── user.gql │ └── services │ │ ├── auth │ │ └── index.ts │ │ ├── notification │ │ ├── addNotification.ts │ │ ├── common.ts │ │ ├── getNotification.ts │ │ ├── index.ts │ │ └── modifyNotification.ts │ │ ├── tweet │ │ ├── addTweet.ts │ │ ├── common.ts │ │ ├── deleteTweet.ts │ │ ├── getTweet.ts │ │ ├── index.ts │ │ └── modifyTweet.ts │ │ ├── upload │ │ └── index.ts │ │ └── user │ │ ├── addUser.ts │ │ ├── getUser.ts │ │ ├── index.ts │ │ └── modifyUser.ts └── tsconfig.json └── fe ├── .env.example ├── .eslintrc ├── .gitignore ├── .storybook └── main.js ├── README.md ├── next.config.js ├── package-lock.json ├── package.json ├── src ├── components │ ├── atoms │ │ ├── Icons │ │ │ ├── Comment.tsx │ │ │ ├── Explore.tsx │ │ │ ├── FullHeart.tsx │ │ │ ├── Heart.tsx │ │ │ ├── Home.tsx │ │ │ ├── Notifications.tsx │ │ │ ├── Picture.tsx │ │ │ ├── Profiles.tsx │ │ │ ├── Retweet.tsx │ │ │ ├── Search.tsx │ │ │ ├── Twitter.tsx │ │ │ ├── X.tsx │ │ │ └── index.ts │ │ ├── Input │ │ │ ├── index.tsx │ │ │ └── input.stories.tsx │ │ ├── NoResult │ │ │ ├── index.tsx │ │ │ └── styled.ts │ │ ├── ProfileImg │ │ │ └── index.tsx │ │ ├── Text │ │ │ ├── index.tsx │ │ │ └── styled.ts │ │ ├── TextArea │ │ │ └── index.tsx │ │ └── index.ts │ ├── molecules │ │ ├── Button │ │ │ ├── button.stories.tsx │ │ │ └── index.tsx │ │ ├── ComponentLoading │ │ │ ├── Point.ts │ │ │ ├── index.tsx │ │ │ └── styled.ts │ │ ├── Footer │ │ │ ├── index.tsx │ │ │ └── styled.ts │ │ ├── IconButton │ │ │ ├── iconButton.stories.tsx │ │ │ └── index.tsx │ │ ├── IconLabel │ │ │ ├── iconlabel.stories.tsx │ │ │ ├── index.tsx │ │ │ └── styled.ts │ │ ├── InputContainer │ │ │ ├── index.tsx │ │ │ ├── inputContainer.stories.tsx │ │ │ └── styled.ts │ │ ├── Loading │ │ │ ├── Point.ts │ │ │ ├── Wave.ts │ │ │ ├── WaveGroup.ts │ │ │ ├── index.tsx │ │ │ └── styled.ts │ │ ├── LoadingCircle │ │ │ ├── index.tsx │ │ │ └── styled.ts │ │ ├── LoginForm │ │ │ ├── index.tsx │ │ │ └── loginForm.stories.tsx │ │ ├── Modal │ │ │ ├── Portal.tsx │ │ │ ├── index.tsx │ │ │ └── styled.ts │ │ ├── SearchBar │ │ │ ├── index.tsx │ │ │ └── searchBar.stories.tsx │ │ ├── TitleSubText │ │ │ └── index.tsx │ │ ├── TweetFooter │ │ │ ├── index.tsx │ │ │ └── styled.ts │ │ ├── UploadImg │ │ │ └── index.tsx │ │ ├── UserInfo │ │ │ ├── index.tsx │ │ │ └── styled.ts │ │ ├── UserPopover │ │ │ └── index.tsx │ │ └── index.ts │ └── organisms │ │ ├── LoginLeftSection │ │ ├── index.tsx │ │ └── styled.ts │ │ ├── LoginRightSection │ │ ├── index.tsx │ │ └── styled.ts │ │ ├── MainContainer │ │ ├── index.tsx │ │ └── styled.ts │ │ ├── NewTweetContainer │ │ ├── index.tsx │ │ └── styled.ts │ │ ├── NotificationContainer │ │ ├── FollowContainer │ │ │ ├── FollowContainer.stories.tsx │ │ │ ├── index.tsx │ │ │ └── styled.ts │ │ ├── HeartContainer │ │ │ ├── index.tsx │ │ │ └── styled.ts │ │ ├── RetweetContainer │ │ │ ├── index.tsx │ │ │ └── styled.ts │ │ ├── index.tsx │ │ └── styled.ts │ │ ├── PageLayout │ │ └── index.tsx │ │ ├── RetweetContainer │ │ ├── index.tsx │ │ └── styled.ts │ │ ├── SideBar │ │ └── index.tsx │ │ ├── SignupModal │ │ ├── index.tsx │ │ └── styled.ts │ │ ├── TweetContainer │ │ ├── index.tsx │ │ └── styled.ts │ │ ├── TweetDetailContainer │ │ ├── index.tsx │ │ └── styled.ts │ │ ├── TweetModal │ │ ├── HeartListModal.tsx │ │ ├── NewTweetModal.tsx │ │ ├── ReplyModal.tsx │ │ ├── RetweetListModal.tsx │ │ ├── RetweetModal.tsx │ │ └── index.ts │ │ ├── UserCard │ │ ├── index.tsx │ │ └── styled.ts │ │ ├── UserDetailContainer │ │ └── index.tsx │ │ ├── UserEditModal │ │ ├── index.tsx │ │ └── styled.ts │ │ └── index.ts ├── graphql │ ├── auth │ │ ├── index.ts │ │ └── login.ts │ ├── custom │ │ ├── customQuery.ts │ │ └── index.ts │ ├── image │ │ ├── addImage.ts │ │ └── index.ts │ ├── notification │ │ ├── getNotification.ts │ │ ├── index.ts │ │ └── modifyNotification.ts │ ├── tweet │ │ ├── addTweet.ts │ │ ├── deleteTweet.ts │ │ ├── getTweet.ts │ │ ├── index.ts │ │ └── modifyTweet.ts │ └── user │ │ ├── addUser.ts │ │ ├── getUser.ts │ │ ├── index.ts │ │ └── modifyUser.ts ├── hooks │ ├── index.ts │ ├── useApollo.ts │ ├── useDataWithInfiniteScroll.ts │ ├── useDisplayWithShallow.ts │ ├── useHeartState.ts │ ├── useHomeTweetListInfiniteScroll.ts │ ├── useInfiniteScroll.ts │ ├── useMyInfo.ts │ ├── useTypeRouter.ts │ └── useUserState.ts ├── libs │ ├── apolloClient.ts │ ├── authProvider.tsx │ ├── index.tsx │ └── utility.ts ├── pages │ ├── [userId] │ │ ├── [[...type]].tsx │ │ └── follow │ │ │ ├── [[...type]].tsx │ │ │ └── styled.ts │ ├── _app.tsx │ ├── _document.tsx │ ├── callback │ │ └── index.tsx │ ├── explore │ │ └── [[...type]].tsx │ ├── index.tsx │ ├── login │ │ └── index.tsx │ ├── notifications │ │ └── [[...type]].tsx │ ├── status │ │ └── [[...type]].tsx │ └── styled.ts ├── styles │ └── global.css └── types │ └── index.ts └── tsconfig.json /.github/ISSUE_TEMPLATE/-----.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 논의 사항 3 | about: 논의 사항에 관련된 내용을 자세하게 적어주세요 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | ### 🌏 논의 주제 10 | 11 | ex) 백엔드 TDD를 어느 수준으로 도입할것인가 12 | 13 | ### 📗 논의 내용 14 | 15 | ex) + service와 api test 테스트의 연관성이 존재 + api에서 status에 따른 response를 handle ~> service의 error 상황은 internal error만 존재 + dependency를 줄이면서 신속한 작업을 위해 api 테스트만 선택 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/new-feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: New Feature 3 | about: 기능 구현 자세하게 해주세요 4 | title: "" 5 | labels: "" 6 | assignees: wseungjin 7 | --- 8 | 9 | ### 💡 목적 10 | 11 | 네비게이션을 위하여 버튼을 생성합니다. 12 | 13 | ### 🛠 구현 세부 사항 14 | 15 | - [ ] 버튼을 클릭하면, alert가 뜹니다 16 | - [ ] 확인 버튼을 누르면, 개발 완료라는 문구가 화면에 표시됩니다. 17 | 18 | ### 🚧 주의 사항 19 | 20 | > 이슈를 구현할 때 유의깊게 살펴볼 사항 21 | 22 | - 주의 사항 1 23 | - 주의 사항 2 24 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### 📕 Issue Number 2 | 3 | Close # 4 | 5 | ### 📙 작업 내역 6 | 7 | > 구현 내용 및 작업 했던 내역 8 | 9 | - [ ] 작업 내역 1 10 | - [ ] 작업 내역 2 11 | - [ ] 작업 내역 3 12 | - [ ] 작업 내역 4 13 | 14 | ### 📘 작업 유형 15 | 16 | - [x] 신규 기능 추가 17 | - [ ] 버그 수정 18 | - [ ] 리펙토링 19 | - [ ] 문서 업데이트 20 | 21 | ### 📋 체크리스트 22 | 23 | - [ ] Merge 하는 브랜치가 올바른가? 24 | - [ ] 코딩컨벤션을 준수하는가? 25 | - [ ] PR과 관련없는 변경사항이 없는가? 26 | - [ ] 내 코드에 대한 자기 검토가 되었는가? 27 |
28 | 29 | ### 📝 PR 특이 사항 30 | 31 | > PR을 볼 때 주의깊게 봐야하거나 말하고 싶은 점 32 | 33 | - 특이 사항 1 34 | - 특이 사항 2 35 | 36 |

37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Twitter Clone Service 🐤 2 | 3 |
4 | 5 | 트위터 로고
6 | 7 | Bwitter는 실시간으로 Follow 및 Follower와 소통이 가능한 Twitter를 Clone한 프로젝트 입니다. 8 | 9 | 10 | ![Javascript](https://img.shields.io/badge/typescript-v4.0.5-blue?logo=typescript) 11 | ![react](https://img.shields.io/badge/react-v17.0.1-1cf?logo=react) 12 | ![NodeJS](https://img.shields.io/badge/node.js-v12.18.3-blackgreen?logo=node.js)
13 | ![NextJS](https://img.shields.io/badge/next.js-v10.0.2-white?logo=next.js) 14 | ![mongoDB](https://img.shields.io/badge/mongoDB-v4.4-green?logo=mongoDB) 15 | ![storybook](https://img.shields.io/badge/storybook-v6.0.28-pink?logo=storybook) 16 | 17 | 18 | [![GitHub Open Issues](https://img.shields.io/github/issues-raw/boostcamp-2020/Project10-twitter?color=green)](https://github.com/boostcamp-2020/Project10-twitter/issues) 19 | [![GitHub Closed Issues](https://img.shields.io/github/issues-closed-raw/boostcamp-2020/Project10-twitter?color=red)](https://github.com/boostcamp-2020/Project10-twitter/issues) 20 | [![GitHub Open PR](https://img.shields.io/github/issues-pr-raw/boostcamp-2020/Project10-twitter?color=green)](https://github.com/boostcamp-2020/Project10-twitter/issues) 21 | [![GitHub Closed PR](https://img.shields.io/github/issues-pr-closed-raw/boostcamp-2020/Project10-twitter?color=red)](https://github.com/boostcamp-2020/Project10-twitter/issues) 22 | 23 |
24 | 25 | ### 🔨 Feature 기능 26 | 27 | ![-10-A팀-Bwitter-twitter와-유사한-clone-서비스](https://user-images.githubusercontent.com/46195613/102567782-95567a00-4125-11eb-8000-be562421f039.png) 28 | 29 | 30 | 31 | ### 실행 화면 32 | 33 | ![bwitter](https://user-images.githubusercontent.com/37282087/102566551-1f511380-4123-11eb-85d2-8dc3d10ec11a.gif) 34 | 35 | ### 팀원 소개 🌸 36 | |![팀원 소개](https://user-images.githubusercontent.com/37282087/102565988-e19fbb00-4121-11eb-89bd-3a960bb7c50e.png)|팀원 소개|![팀원 소개](https://user-images.githubusercontent.com/37282087/102566074-0dbb3c00-4122-11eb-91dd-f6635bdc863d.png)| 37 | |:-:|:-:|:-:| 38 | |J093_백지영 (WEB)|J121_우승진 (WEB)|J198_주재우 (WEB)| 39 | |[16010948](https://github.com/16010948)|[wseungjin](https://github.com/wseungjin)|[joojaewoo](https://github.com/joojaewoo) 40 | 41 | 42 | ### 배포주소 🚌 43 | [여기로!](http://bwitter.online/) 44 | 45 | ### 실행 방법 🎇 46 | [**BackEnd**](https://github.com/boostcamp-2020/Project10-Twitter/blob/master/be/README.md) 47 | [**FrontEnd**](https://github.com/boostcamp-2020/Project10-Twitter/blob/master/fe/README.md) 48 | 49 | ### 팀 Wiki 🎀 50 | [wiki](https://github.com/boostcamp-2020/Project10-Twitter/wiki) 51 | 52 | ### 기획서 🕊 53 | [기획서](https://docs.google.com/presentation/d/1Z-6bAUYS0ykckSjUamXW_-raecI_ycFpNTOuYgbkYC4/edit#slide=id.gab4c4bdf1b_8_0) 54 | 55 | ### BackLog 💻 56 | [Backlog](https://docs.google.com/spreadsheets/d/1jSSyyLospR7FUJlVK8pi8LBlfyueRmd7l_AujQtkWI0/edit#gid=302595969) 57 | 58 | ### Document 📃 59 | - [그라운드 룰](https://github.com/boostcamp-2020/Project10-Twitter/wiki/%EA%B7%B8%EB%9D%BC%EC%9A%B4%EB%93%9C-%EB%A3%B0) 60 | - [ERD](https://github.com/boostcamp-2020/Project10-Twitter/wiki/DB-%EB%AA%85%EC%84%B8%EC%84%9C-&-%EC%8A%A4%ED%82%A4%EB%A7%88) 61 | - [커밋 컨벤션](https://github.com/boostcamp-2020/Project10-Twitter/wiki/%EC%BB%A4%EB%B0%8B-%EC%BB%A8%EB%B2%A4%EC%85%98) 62 | - [코딩 컨벤션](https://github.com/boostcamp-2020/Project10-Twitter/wiki/%EC%BD%94%EB%94%A9-%EC%BB%A8%EB%B2%A4%EC%85%98) -------------------------------------------------------------------------------- /be/.env.example: -------------------------------------------------------------------------------- 1 | PORT = 2 | 3 | GITHUB_GET_TOKEN_URL = 4 | GITHUB_GET_USER_URL = 5 | 6 | DEV_DB_URL = 7 | DEV_ORIGIN = 8 | 9 | DEV_GITHUB_CLIENT_ID = 10 | DEV_GITHUB_CLIENT_SECRET = 11 | DEV_IMG_URL = 12 | 13 | PRO_DB_URL = 14 | PRO_ORIGIN = 15 | 16 | PRO_GITHUB_CLIENT_ID = 17 | PRO_GITHUB_CLIENT_SECRET = 18 | PRO_IMG_URL = 19 | 20 | JWT_SECRET_KEY = -------------------------------------------------------------------------------- /be/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": ["airbnb-base", "prettier"], 4 | "env": { 5 | "browser": true, 6 | "node": true 7 | }, 8 | "plugins": ["@typescript-eslint", "prettier"], 9 | "rules": { 10 | "prettier/prettier": ["error", { "endOfLine": "auto" }], 11 | "no-console": "off", 12 | "import/extensions": 0, 13 | "import/no-unresolved": 0, 14 | "camelcase": 0 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /be/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | uploads -------------------------------------------------------------------------------- /be/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": true, 4 | "useTabs": false, 5 | "tabWidth": 2, 6 | "trailingComma": "all", 7 | "printWidth": 100, 8 | "arrowParens": "always" 9 | } -------------------------------------------------------------------------------- /be/README.md: -------------------------------------------------------------------------------- 1 | ## Twitter Clone Service 🐤 2 | 3 |
4 | 5 | 트위터 로고
6 | 7 |
8 | 9 | Bwitter는 실시간으로 Follow 및 Follower와 소통이 가능한 Twitter를 Clone한 프로젝트 입니다. 10 | 11 | ## 실행 스크립트 12 | ``` 13 | npm i 14 | 15 | npm run dev #development 16 | ``` 17 | 18 | ## 폴더 구조 19 | ``` 20 | 📁be 21 | ├── 📄 README.md - 리드미 파일 22 | │ 23 | ├── 📁 src/ - 소스 폴더 24 | │ ├── 📁 libs/ - 직접 구현한 라이브러리 25 | │ ├── 📁 models/ - 모델 정의 26 | │ ├── 📁 providers/ - 서버 기본 설정 제공 27 | │ ├── 📁 resolvers/ - service와 schema 연결 28 | │ ├── 📁 schema/ - db query schema 선언 29 | │ ├── 📁 service/ - db query service 로직 30 | │ └──📄 app.ts - 어플리케이션 파일 31 | └── 📁uploads - 업로드 한 이미지가 저장된 폴더 32 | ``` 33 | -------------------------------------------------------------------------------- /be/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "twitter-api", 3 | "version": "0.0.0", 4 | "description": "this is twitter-api", 5 | "main": "app.js", 6 | "scripts": { 7 | "dev": "cross-env NODE_ENV=development nodemon --exec ts-node -r tsconfig-paths/register src/app.ts", 8 | "prod": "cross-env NODE_ENV=production nodemon --exec ts-node -r tsconfig-paths/register src/app.ts", 9 | "prod-dist": "pm2 start npm --name 'bwitter-api-dist' -- run prod" 10 | }, 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "@graphql-tools/load-files": "^6.2.5", 15 | "@graphql-tools/merge": "^6.2.6", 16 | "@types/cookie-parser": "^1.4.2", 17 | "apollo-server-express": "^2.19.0", 18 | "axios": "^0.21.0", 19 | "bcrypt": "^5.0.0", 20 | "cookie-parser": "^1.4.5", 21 | "cors": "^2.8.5", 22 | "dotenv": "^8.2.0", 23 | "express": "^4.17.1", 24 | "express-graphql": "^0.11.0", 25 | "graphql": "^15.4.0", 26 | "graphql-tools": "^7.0.2", 27 | "graphql-upload": "^11.0.0", 28 | "jsonwebtoken": "^8.5.1", 29 | "mongoose": "^5.10.15", 30 | "morgan": "^1.10.0" 31 | }, 32 | "devDependencies": { 33 | "@types/bcrypt": "^3.0.0", 34 | "@types/express": "^4.17.9", 35 | "@types/jsonwebtoken": "^8.5.0", 36 | "@types/mongoose": "^5.10.1", 37 | "@types/morgan": "^1.9.2", 38 | "@typescript-eslint/eslint-plugin": "^4.8.1", 39 | "@typescript-eslint/parser": "^4.8.1", 40 | "cross-env": "^7.0.2", 41 | "eslint": "^7.14.0", 42 | "eslint-config-airbnb-base": "^14.2.1", 43 | "eslint-config-prettier": "^6.15.0", 44 | "eslint-plugin-import": "^2.22.1", 45 | "nodemon": "^2.0.6", 46 | "pm2": "^4.5.0", 47 | "prettier": "^2.2.0", 48 | "ts-node": "^9.0.0", 49 | "tsconfig-paths": "^3.9.0", 50 | "typescript": "^4.1.2" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /be/src/app.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { ApolloServer } from 'apollo-server-express'; 3 | import { graphqlUploadExpress } from 'graphql-upload'; 4 | 5 | import logger from 'morgan'; 6 | import path from 'path'; 7 | import cors from 'cors'; 8 | import dotenv from 'dotenv'; 9 | import cookieParser from 'cookie-parser'; 10 | 11 | import typeDefs from '@schema'; 12 | import resolvers from '@resolvers'; 13 | import { verifyToken } from '@libs/jwt-token'; 14 | import dbStarter from '@providers/dbProvider'; 15 | 16 | dotenv.config(); 17 | 18 | const app = express(); 19 | 20 | const ORIGIN = 21 | process.env.NODE_ENV === 'development' ? process.env.DEV_ORIGIN : process.env.PRO_ORIGIN; 22 | 23 | const corsOptions = { origin: ORIGIN, credentials: true }; 24 | 25 | app.use(cookieParser()); 26 | app.use(logger('dev')); 27 | app.use(express.static(path.join(__dirname, '../uploads'))); 28 | app.use(cors(corsOptions)); 29 | app.use(graphqlUploadExpress()); 30 | const port: number = Number(process.env.PORT) || 3000; 31 | 32 | const server = new ApolloServer({ 33 | typeDefs, 34 | resolvers, 35 | uploads: false, // Here! 36 | context: ({ req, res }) => { 37 | if (!req.cookies.jwt) return { authUser: undefined, res }; 38 | 39 | const authUser = verifyToken(req.cookies.jwt); 40 | return { authUser, res }; 41 | }, 42 | formatError: (err) => ({ message: err.message }), 43 | }); 44 | server.applyMiddleware({ app, cors: corsOptions, path: '/graphql' }); 45 | 46 | const booting = async () => { 47 | await dbStarter(); 48 | app.listen(port, () => { 49 | console.log(`Example app listening on port ${port}!`); 50 | }); 51 | }; 52 | 53 | booting(); 54 | -------------------------------------------------------------------------------- /be/src/libs/hash-password.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcrypt'; 2 | 3 | const getHashedPassword = async (password: string) => { 4 | const hash = await bcrypt.hash(password, 10); 5 | return hash; 6 | }; 7 | 8 | export default getHashedPassword; 9 | -------------------------------------------------------------------------------- /be/src/libs/jwt-token.ts: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | 3 | const signToken = ({ id }: { id: string }) => { 4 | const token = jwt.sign({ id }, process.env.JWT_SECRET_KEY!, { expiresIn: '24h' }); 5 | return token; 6 | }; 7 | 8 | const verifyToken = (token: string) => { 9 | try { 10 | const user = jwt.verify(token, process.env.JWT_SECRET_KEY!); 11 | return user; 12 | } catch { 13 | return null; 14 | } 15 | }; 16 | 17 | export { signToken, verifyToken }; 18 | -------------------------------------------------------------------------------- /be/src/libs/utiltys.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | const makeRandomName = (nameLength: number): string => { 4 | const result = Math.random() 5 | .toString(36) 6 | .substring(2, 2 + nameLength); 7 | return result; 8 | }; 9 | 10 | const stringToObjectId = (stringId: string): mongoose.Types.ObjectId => { 11 | return mongoose.Types.ObjectId(stringId); 12 | }; 13 | 14 | export { makeRandomName, stringToObjectId }; 15 | -------------------------------------------------------------------------------- /be/src/models/index.ts: -------------------------------------------------------------------------------- 1 | import userModel from './user'; 2 | import tweetModel from './tweet'; 3 | import notificationModel from './notification'; 4 | 5 | export { userModel, tweetModel, notificationModel }; 6 | -------------------------------------------------------------------------------- /be/src/models/notification.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | const { Schema } = mongoose; 4 | 5 | const notificationSchema = new Schema( 6 | { 7 | user_id: String, 8 | tweet_id: Schema.Types.ObjectId, 9 | giver_id: String, 10 | type: String, 11 | createAt: { type: Date, default: Date.now }, 12 | }, 13 | { versionKey: false }, 14 | ); 15 | 16 | const notificationModel = mongoose.model('notification', notificationSchema); 17 | 18 | export default notificationModel; 19 | -------------------------------------------------------------------------------- /be/src/models/tweet.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | const { Schema } = mongoose; 4 | 5 | const tweetSchema = new Schema( 6 | { 7 | author_id: String, 8 | content: String, 9 | img_url_list: [String], 10 | parent_id: Schema.Types.ObjectId, 11 | retweet_id: Schema.Types.ObjectId, 12 | child_tweet_id_list: [Schema.Types.ObjectId], 13 | retweet_user_id_list: [String], 14 | heart_user_id_list: [String], 15 | createAt: { type: Date, default: Date.now }, 16 | }, 17 | { versionKey: false }, 18 | ); 19 | 20 | const tweetModel = mongoose.model('tweet', tweetSchema); 21 | 22 | export default tweetModel; 23 | -------------------------------------------------------------------------------- /be/src/models/user.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | const { Schema } = mongoose; 4 | 5 | const userSchema = new Schema( 6 | { 7 | user_id: { type: String, unique: true }, 8 | name: String, 9 | password: String, 10 | following_id_list: [String], 11 | comment: String, 12 | github_id: String, 13 | profile_img_url: String, 14 | background_img_url: String, 15 | heart_tweet_id_list: [mongoose.Types.ObjectId], 16 | lastest_notification_id: Schema.Types.ObjectId, 17 | }, 18 | { versionKey: false }, 19 | ); 20 | 21 | const userModel = mongoose.model('user', userSchema); 22 | 23 | export default userModel; 24 | -------------------------------------------------------------------------------- /be/src/providers/dbProvider.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | const dbStarter = async () => { 4 | const CONNECT_URL = 5 | process.env.NODE_ENV === 'development' ? process.env.DEV_DB_URL : process.env.PRO_DB_URL; 6 | if (CONNECT_URL === undefined) throw Error('db connection fail'); 7 | await mongoose.connect(CONNECT_URL, { 8 | useNewUrlParser: true, 9 | useFindAndModify: false, 10 | }); 11 | }; 12 | 13 | export default dbStarter; 14 | -------------------------------------------------------------------------------- /be/src/resolvers/file.ts: -------------------------------------------------------------------------------- 1 | import { IResolvers } from 'apollo-server-express'; 2 | import { imgUpload } from '@services/upload'; 3 | 4 | const fileResolvers: IResolvers = { 5 | Query: { 6 | upload_images: () => {}, 7 | }, 8 | Mutation: { 9 | single_upload: imgUpload, 10 | }, 11 | }; 12 | 13 | export default fileResolvers; 14 | -------------------------------------------------------------------------------- /be/src/resolvers/index.ts: -------------------------------------------------------------------------------- 1 | import { mergeResolvers } from '@graphql-tools/merge'; 2 | import { loadFilesSync } from '@graphql-tools/load-files'; 3 | 4 | const resolversArray = loadFilesSync(__dirname, { extensions: ['ts'] }); 5 | 6 | const resolvers = mergeResolvers(resolversArray); 7 | export default resolvers; 8 | -------------------------------------------------------------------------------- /be/src/resolvers/notification.ts: -------------------------------------------------------------------------------- 1 | import { IResolvers } from 'apollo-server-express'; 2 | import { 3 | getNotification, 4 | getNotificationCount, 5 | updateNotification, 6 | getNotificationWithMention, 7 | } from '@services/notification'; 8 | 9 | const notificationResolvers: IResolvers = { 10 | Query: { 11 | notification_list: getNotification, 12 | notification_mention_list: getNotificationWithMention, 13 | notification_count: getNotificationCount, 14 | }, 15 | Mutation: { 16 | update_notification: updateNotification, 17 | }, 18 | }; 19 | 20 | export default notificationResolvers; 21 | -------------------------------------------------------------------------------- /be/src/resolvers/tweet.ts: -------------------------------------------------------------------------------- 1 | import { IResolvers } from 'apollo-server-express'; 2 | import { 3 | getFollowingTweetList, 4 | getUserTweetList, 5 | getUserAllTweetList, 6 | addBasicTweet, 7 | addReplyTweet, 8 | addRetweet, 9 | deleteTweet, 10 | heartTweet, 11 | unheartTweet, 12 | getDetailTweet, 13 | getChildTweetList, 14 | getHeartTweetList, 15 | getSearchedTweetList, 16 | getLatestTweetList, 17 | } from '@services/tweet'; 18 | 19 | const tweetResolvers: IResolvers = { 20 | Query: { 21 | following_tweet_list: getFollowingTweetList, 22 | user_tweet_list: getUserTweetList, 23 | user_all_tweet_list: getUserAllTweetList, 24 | child_tweet_list: getChildTweetList, 25 | detail_tweet: getDetailTweet, 26 | heart_tweet_list: getHeartTweetList, 27 | search_tweet_list: getSearchedTweetList, 28 | latest_tweet_list: getLatestTweetList, 29 | }, 30 | Mutation: { 31 | add_basic_tweet: addBasicTweet, 32 | add_reply_tweet: addReplyTweet, 33 | add_retweet: addRetweet, 34 | delete_tweet: deleteTweet, 35 | heart_tweet: heartTweet, 36 | unheart_tweet: unheartTweet, 37 | }, 38 | }; 39 | 40 | export default tweetResolvers; 41 | -------------------------------------------------------------------------------- /be/src/resolvers/user.ts: -------------------------------------------------------------------------------- 1 | import { IResolvers } from 'apollo-server-express'; 2 | import { 3 | getFollowingList, 4 | getFollowerList, 5 | getSearchedUserList, 6 | getHeartUserList, 7 | getRetweetUserList, 8 | getUserInfo, 9 | getMyUserInfo, 10 | followUser, 11 | unfollowUser, 12 | createUser, 13 | updateUserInfo, 14 | getFollowerCount, 15 | } from '@services/user'; 16 | 17 | import { githubLogin, localLogin } from '@services/auth'; 18 | 19 | const userResolvers: IResolvers = { 20 | Query: { 21 | following_list: getFollowingList, 22 | follower_list: getFollowerList, 23 | heart_user_list: getHeartUserList, 24 | retweet_user_list: getRetweetUserList, 25 | search_user_list: getSearchedUserList, 26 | my_info: getMyUserInfo, 27 | user_info: getUserInfo, 28 | follower_count: getFollowerCount, 29 | }, 30 | Mutation: { 31 | update_user_info: updateUserInfo, 32 | create_user: createUser, 33 | github_login: githubLogin, 34 | local_login: localLogin, 35 | follow_user: followUser, 36 | unfollow_user: unfollowUser, 37 | }, 38 | }; 39 | 40 | export default userResolvers; 41 | -------------------------------------------------------------------------------- /be/src/schema/common.gql: -------------------------------------------------------------------------------- 1 | type Response { 2 | response: Boolean 3 | } 4 | scalar Date 5 | -------------------------------------------------------------------------------- /be/src/schema/file.gql: -------------------------------------------------------------------------------- 1 | type File { 2 | filename: String! 3 | mimetype: String! 4 | encoding: String! 5 | } 6 | type Image { 7 | img_url: String 8 | } 9 | 10 | type Query { 11 | upload_images: [File] 12 | } 13 | 14 | scalar Upload 15 | 16 | type Mutation { 17 | single_upload(file: Upload!): Image 18 | } 19 | -------------------------------------------------------------------------------- /be/src/schema/index.ts: -------------------------------------------------------------------------------- 1 | import { mergeTypeDefs } from '@graphql-tools/merge'; 2 | import { loadFilesSync } from '@graphql-tools/load-files'; 3 | 4 | const typesArray = loadFilesSync(__dirname, { extensions: ['gql'] }); 5 | 6 | const typeDef = mergeTypeDefs(typesArray); 7 | 8 | export default typeDef; 9 | -------------------------------------------------------------------------------- /be/src/schema/notification.gql: -------------------------------------------------------------------------------- 1 | type Notification { 2 | giver: User 3 | user_id: String 4 | tweet: Tweet 5 | type: String 6 | _id: String 7 | createAt: Date 8 | } 9 | type Count { 10 | count: Int 11 | } 12 | 13 | type Query { 14 | notification_count(lastest_notification_id: String): Count 15 | notification_list(oldest_notification_id: String): [Notification] 16 | notification_mention_list(oldest_notification_id: String): [Notification] 17 | } 18 | 19 | type Mutation { 20 | update_notification(id: String): Response 21 | } 22 | -------------------------------------------------------------------------------- /be/src/schema/tweet.gql: -------------------------------------------------------------------------------- 1 | type Tweet { 2 | _id: String 3 | author_id: String 4 | author: User 5 | content: String 6 | img_url_list: [String] 7 | parent_id: String 8 | retweet_id: String 9 | retweet: Tweet 10 | child_tweet_number: Int 11 | retweet_user_number: Int 12 | heart_user_number: Int 13 | createAt: Date 14 | } 15 | 16 | type Query { 17 | following_tweet_list(oldest_tweet_id: String, latest_tweet_id: String): [Tweet] 18 | user_tweet_list(user_id: String, oldest_tweet_id: String): [Tweet] 19 | user_all_tweet_list(user_id: String, oldest_tweet_id: String): [Tweet] 20 | child_tweet_list(tweet_id: String!, oldest_tweet_id: String): [Tweet] 21 | detail_tweet(tweet_id: String!): Tweet 22 | heart_tweet_list(user_id: String!, oldest_tweet_id: String): [Tweet] 23 | search_tweet_list(search_word: String!, oldest_tweet_id: String): [Tweet] 24 | latest_tweet_list(latest_tweet_id: String): [Tweet] 25 | } 26 | 27 | type Mutation { 28 | add_basic_tweet(content: String!, img_url_list: [String]): Tweet 29 | add_reply_tweet(content: String!, img_url_list: [String], parent_id: String!): Tweet 30 | add_retweet(content: String, retweet_id: String!): Tweet 31 | delete_tweet(tweet_id: String!): Response 32 | heart_tweet(tweet_id: String!): Tweet 33 | unheart_tweet(tweet_id: String!): Tweet 34 | } 35 | -------------------------------------------------------------------------------- /be/src/schema/user.gql: -------------------------------------------------------------------------------- 1 | type User { 2 | _id: String 3 | user_id: String 4 | name: String 5 | profile_img_url: String 6 | comment: String 7 | background_img_url: String 8 | following_id_list: [String] 9 | heart_tweet_id_list: [String] 10 | lastest_notification_id: String 11 | } 12 | type Auth { 13 | token: String 14 | } 15 | type Follower { 16 | count: Int 17 | } 18 | type Query { 19 | following_list(user_id: String, oldest_user_id: String): [User] 20 | follower_list(user_id: String, oldest_user_id: String): [User] 21 | heart_user_list(tweet_id: String, oldest_user_id: String): [User] 22 | retweet_user_list(tweet_id: String, oldest_user_id: String): [User] 23 | search_user_list(search_word: String!, oldest_user_id: String): [User] 24 | my_info: User 25 | user_info(user_id: String): User 26 | follower_count(user_id: String): Follower 27 | } 28 | 29 | type Mutation { 30 | github_login(code: String!): Auth 31 | create_user(user_id: String!, name: String!, password: String!): Response 32 | local_login(user_id: String!, password: String!): Auth 33 | update_user_info(name: String!, comment: String): Response 34 | follow_user(follow_user_id: String!): User 35 | unfollow_user(unfollow_user_id: String!): User 36 | } 37 | -------------------------------------------------------------------------------- /be/src/services/auth/index.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcrypt'; 2 | import axios from 'axios'; 3 | import { signToken } from '@libs/jwt-token'; 4 | import { userModel } from '@models'; 5 | import { registerUser } from '@services/user/addUser'; 6 | 7 | interface UserInfo { 8 | user_id: string; 9 | github_id: number; 10 | name: string; 11 | profile_img_url?: string; 12 | } 13 | 14 | interface GithubUserInfo { 15 | login: string; 16 | id: number; 17 | avatar_url: string; 18 | name: string; 19 | } 20 | 21 | const getGithubToken = async (code: string) => { 22 | const githubClientId = 23 | process.env.NODE_ENV === 'development' 24 | ? process.env.DEV_GITHUB_CLIENT_ID 25 | : process.env.PRO_GITHUB_CLIENT_ID; 26 | const githubClientSecret = 27 | process.env.NODE_ENV === 'development' 28 | ? process.env.DEV_GITHUB_CLIENT_SECRET 29 | : process.env.PRO_GITHUB_CLIENT_SECRET; 30 | 31 | const { data } = await axios.post( 32 | process.env.GITHUB_GET_TOKEN_URL as string, 33 | { 34 | code, 35 | client_id: githubClientId, 36 | client_secret: githubClientSecret, 37 | }, 38 | { 39 | headers: { 40 | accept: 'application/json', 41 | }, 42 | }, 43 | ); 44 | return data.access_token; 45 | }; 46 | 47 | const getGithubUserInfo = async (githubToken: string) => { 48 | const { data }: { data: GithubUserInfo } = await axios.get( 49 | process.env.GITHUB_GET_USER_URL as string, 50 | { 51 | headers: { 52 | Authorization: `token ${githubToken}`, 53 | }, 54 | }, 55 | ); 56 | return data; 57 | }; 58 | 59 | const getOurUser = async (userInfo: UserInfo) => { 60 | let user = await userModel.findOne({ github_id: userInfo.github_id }); 61 | if (!user) { 62 | user = await registerUser(userInfo); 63 | } 64 | return user; 65 | }; 66 | 67 | const githubLogin = async (_: any, { code }: { code: string }, { res }: any) => { 68 | const githubToken = await getGithubToken(code); 69 | const githubUserInfo = await getGithubUserInfo(githubToken); 70 | const user = await getOurUser({ 71 | user_id: githubUserInfo.login, 72 | github_id: githubUserInfo.id, 73 | name: githubUserInfo.name, 74 | profile_img_url: githubUserInfo.avatar_url, 75 | }); 76 | const signedToken = signToken({ id: user.get('user_id') }); 77 | res.cookie('jwt', signedToken); 78 | return { token: signedToken }; 79 | }; 80 | 81 | const localLogin = async ( 82 | _: any, 83 | { user_id, password }: { user_id: string; password: string }, 84 | { res }: any, 85 | ) => { 86 | const userInfo = await userModel.findOne({ user_id }); 87 | 88 | if (!userInfo) throw new Error('Not Found User'); 89 | 90 | const dbPassword = userInfo?.get('password'); 91 | const isLogined = await bcrypt.compare(password, dbPassword); 92 | 93 | if (!isLogined) throw new Error('Wrong Password'); 94 | 95 | const signedToken = signToken({ id: userInfo?.get('user_id') }); 96 | res.cookie('jwt', signedToken); 97 | return { token: signedToken }; 98 | }; 99 | 100 | export { githubLogin, localLogin }; 101 | -------------------------------------------------------------------------------- /be/src/services/notification/addNotification.ts: -------------------------------------------------------------------------------- 1 | import { notificationModel } from '@models'; 2 | 3 | interface Args { 4 | userId: string; 5 | type: string; 6 | giverId?: string; 7 | tweetId?: string; 8 | } 9 | 10 | const createNotification = async ({ userId, giverId, tweetId, type }: Args) => { 11 | await notificationModel.create({ 12 | user_id: userId, 13 | giver_id: giverId, 14 | tweet_id: tweetId, 15 | type, 16 | }); 17 | }; 18 | 19 | export default createNotification; 20 | -------------------------------------------------------------------------------- /be/src/services/notification/common.ts: -------------------------------------------------------------------------------- 1 | const commonNotificationCondition = [ 2 | { 3 | $lookup: { 4 | from: 'tweets', 5 | localField: 'tweet_id', 6 | foreignField: '_id', 7 | as: 'tweet', 8 | }, 9 | }, 10 | { $unwind: { path: '$tweet', preserveNullAndEmptyArrays: true } }, 11 | { 12 | $lookup: { 13 | from: 'users', 14 | localField: 'tweet.author_id', 15 | foreignField: 'user_id', 16 | as: 'tweet.author', 17 | }, 18 | }, 19 | { $unwind: { path: '$tweet.author', preserveNullAndEmptyArrays: true } }, 20 | { 21 | $lookup: { 22 | from: 'users', 23 | localField: 'giver_id', 24 | foreignField: 'user_id', 25 | as: 'giver', 26 | }, 27 | }, 28 | { $unwind: { path: '$giver', preserveNullAndEmptyArrays: true } }, 29 | { 30 | $project: { 31 | _id: 1, 32 | tweet_id: 1, 33 | giver_id: 1, 34 | type: 1, 35 | createAt: 1, 36 | giver: 1, 37 | 'tweet._id': 1, 38 | 'tweet.author_id': 1, 39 | 'tweet.author': 1, 40 | 'tweet.content': 1, 41 | 'tweet.parent_id': 1, 42 | 'tweet.retweet_id': 1, 43 | 'tweet.img_url_list': 1, 44 | 'tweet.child_tweet_id_list': 1, 45 | 'tweet.child_tweet_number': { 46 | $size: { $ifNull: ['$tweet.child_tweet_id_list', []] }, 47 | }, 48 | 'tweet.retweet_user_id_list': 1, 49 | 'tweet.retweet_user_number': { 50 | $size: { $ifNull: ['$tweet.retweet_user_id_list', []] }, 51 | }, 52 | 'tweet.heart_user_id_list': 1, 53 | 'tweet.heart_user_number': { 54 | $size: { $ifNull: ['$tweet.heart_user_id_list', []] }, 55 | }, 56 | 'tweet.createAt': 1, 57 | }, 58 | }, 59 | ]; 60 | 61 | export default commonNotificationCondition; 62 | -------------------------------------------------------------------------------- /be/src/services/notification/getNotification.ts: -------------------------------------------------------------------------------- 1 | import { AuthenticationError } from 'apollo-server-express'; 2 | import { notificationModel } from '@models'; 3 | import { stringToObjectId } from '@libs/utiltys'; 4 | import commonNotificationCondition from './common'; 5 | 6 | interface Auth { 7 | authUser: { id: string }; 8 | } 9 | 10 | const getNextnotificationsCondition = (oldest_notification_id: string): Object => { 11 | return oldest_notification_id ? { _id: { $lt: stringToObjectId(oldest_notification_id) } } : {}; 12 | }; 13 | 14 | const getNotification = async ( 15 | _: any, 16 | { oldest_notification_id }: { oldest_notification_id: string }, 17 | { authUser }: Auth, 18 | ) => { 19 | if (!authUser) throw new AuthenticationError('not authenticated'); 20 | 21 | const userId = authUser.id; 22 | 23 | const nextNotificationcondition = getNextnotificationsCondition(oldest_notification_id); 24 | 25 | const notifications: Document[] = await notificationModel.aggregate([ 26 | { 27 | $match: { 28 | $and: [{ user_id: userId }, nextNotificationcondition], 29 | }, 30 | }, 31 | { $sort: { createAt: -1 } }, 32 | { $limit: 20 }, 33 | ...commonNotificationCondition, 34 | ]); 35 | 36 | return notifications; 37 | }; 38 | 39 | const getNotificationWithMention = async ( 40 | _: any, 41 | { oldest_notification_id }: { oldest_notification_id: string }, 42 | { authUser }: Auth, 43 | ) => { 44 | if (!authUser) throw new AuthenticationError('not authenticated'); 45 | 46 | const userId = authUser.id; 47 | 48 | const nextNotificationcondition = getNextnotificationsCondition(oldest_notification_id); 49 | 50 | const notifications: Document[] = await notificationModel.aggregate([ 51 | { 52 | $match: { 53 | $and: [{ user_id: userId }, { type: 'mention' }, nextNotificationcondition], 54 | }, 55 | }, 56 | { $sort: { createAt: -1 } }, 57 | { $limit: 20 }, 58 | ...commonNotificationCondition, 59 | ]); 60 | 61 | return notifications; 62 | }; 63 | 64 | const getNotificationCount = async ( 65 | _: any, 66 | { lastest_notification_id }: { lastest_notification_id: string }, 67 | { authUser }: Auth, 68 | ) => { 69 | if (!authUser) throw new AuthenticationError('not authenticated'); 70 | 71 | const userId = authUser.id; 72 | 73 | const condition = lastest_notification_id 74 | ? { _id: { $gt: stringToObjectId(lastest_notification_id) } } 75 | : {}; 76 | const [count]: Document[] = await notificationModel.aggregate([ 77 | { 78 | $match: { 79 | $and: [{ user_id: userId }, condition], 80 | }, 81 | }, 82 | { 83 | $count: 'count', 84 | }, 85 | ]); 86 | return count || { count: 0 }; 87 | }; 88 | 89 | export { getNotificationCount, getNotification, getNotificationWithMention }; 90 | -------------------------------------------------------------------------------- /be/src/services/notification/index.ts: -------------------------------------------------------------------------------- 1 | import createNotification from './addNotification'; 2 | import { 3 | getNotificationCount, 4 | getNotificationWithMention, 5 | getNotification, 6 | } from './getNotification'; 7 | import updateNotification from './modifyNotification'; 8 | 9 | export { 10 | getNotificationCount, 11 | createNotification, 12 | getNotification, 13 | updateNotification, 14 | getNotificationWithMention, 15 | }; 16 | -------------------------------------------------------------------------------- /be/src/services/notification/modifyNotification.ts: -------------------------------------------------------------------------------- 1 | import { AuthenticationError } from 'apollo-server-express'; 2 | import { userModel } from '@models'; 3 | 4 | interface Auth { 5 | authUser: { id: string }; 6 | } 7 | 8 | const updateNotification = async (_: any, { id }: { id: string }, { authUser }: Auth) => { 9 | if (!authUser) throw new AuthenticationError('not authenticated'); 10 | 11 | const userId = authUser.id; 12 | await userModel.updateOne({ user_id: userId }, { $set: { lastest_notification_id: id } }); 13 | 14 | return { response: true }; 15 | }; 16 | 17 | export default updateNotification; 18 | -------------------------------------------------------------------------------- /be/src/services/tweet/addTweet.ts: -------------------------------------------------------------------------------- 1 | import { AuthenticationError } from 'apollo-server-express'; 2 | import { tweetModel, userModel } from '@models'; 3 | import { createNotification } from '@services/notification'; 4 | 5 | interface Auth { 6 | authUser: { id: string }; 7 | } 8 | 9 | interface Args { 10 | content: string; 11 | img_url_list?: [string]; 12 | parent_id?: string; 13 | retweet_id?: string; 14 | } 15 | 16 | const findMentionUser = async (content: string, tweetId: string, giverId: string) => { 17 | const users = content.match(/@[a-zA-Z0-9]+/gi); 18 | if (users) { 19 | const test = users.map((user) => user.replace(/@/g, '')); 20 | const userInfo = await userModel.find({ user_id: { $in: test } }); 21 | userInfo.map(async (user) => { 22 | await createNotification({ userId: user.get('user_id'), type: 'mention', tweetId, giverId }); 23 | }); 24 | } 25 | }; 26 | 27 | const addBasicTweet = async (_: any, { content, img_url_list }: Args, { authUser }: Auth) => { 28 | if (!authUser) throw new AuthenticationError('not authenticated'); 29 | if (!content && !img_url_list) throw new Error('빈 트윗입니다.'); 30 | 31 | const userId = authUser.id; 32 | const newTweet = await tweetModel.create({ 33 | author_id: userId, 34 | content, 35 | img_url_list, 36 | child_tweet_id_list: [], 37 | retweet_list: [], 38 | heart_list: [], 39 | }); 40 | 41 | await findMentionUser(content, newTweet.get('_id'), userId); 42 | 43 | return newTweet; 44 | }; 45 | 46 | const addReplyTweet = async ( 47 | _: any, 48 | { content, img_url_list, parent_id }: Args, 49 | { authUser }: Auth, 50 | ) => { 51 | if (!authUser) throw new AuthenticationError('not authenticated'); 52 | if (!parent_id) throw new Error('parent 트윗이 존재하지 않습니다.'); 53 | 54 | const userId = authUser.id; 55 | const replyTweet = await tweetModel.create({ 56 | author_id: userId, 57 | content, 58 | img_url_list, 59 | parent_id, 60 | child_tweet_id_list: [], 61 | retweet_list: [], 62 | heart_list: [], 63 | }); 64 | const childId = replyTweet?.get('_id'); 65 | const parentTweet = await tweetModel.findOneAndUpdate( 66 | { _id: parent_id }, 67 | { $push: { child_tweet_id_list: childId } }, 68 | { new: true }, 69 | ); 70 | 71 | await createNotification({ 72 | userId: parentTweet?.get('author_id'), 73 | tweetId: childId, 74 | type: 'reply', 75 | giverId: userId, 76 | }); 77 | 78 | await findMentionUser(content, replyTweet.get('_id'), userId); 79 | 80 | return replyTweet; 81 | }; 82 | 83 | const addRetweet = async (_: any, { content, retweet_id }: Args, { authUser }: Auth) => { 84 | if (!authUser) throw new AuthenticationError('not authenticated'); 85 | if (!retweet_id) throw new Error('retweet할 트윗이 존재하지 않습니다.'); 86 | 87 | const userId = authUser.id; 88 | 89 | const newRetweet = await tweetModel.create({ 90 | author_id: userId, 91 | content, 92 | retweet_id, 93 | child_tweet_id_list: [], 94 | retweet_list: [], 95 | heart_list: [], 96 | }); 97 | 98 | const parentTweet = await tweetModel.findOneAndUpdate( 99 | { _id: retweet_id }, 100 | { $push: { retweet_user_id_list: userId } }, 101 | { new: true }, 102 | ); 103 | 104 | await createNotification({ 105 | userId: parentTweet?.get('author_id'), 106 | tweetId: newRetweet?.get('_id'), 107 | type: 'retweet', 108 | giverId: userId, 109 | }); 110 | 111 | await findMentionUser(content, newRetweet.get('_id'), userId); 112 | 113 | return newRetweet; 114 | }; 115 | 116 | export { addBasicTweet, addReplyTweet, addRetweet }; 117 | -------------------------------------------------------------------------------- /be/src/services/tweet/common.ts: -------------------------------------------------------------------------------- 1 | const commonReadCondition = [ 2 | { 3 | $lookup: { 4 | from: 'users', 5 | localField: 'author_id', 6 | foreignField: 'user_id', 7 | as: 'author', 8 | }, 9 | }, 10 | { $unwind: '$author' }, 11 | { 12 | $lookup: { 13 | from: 'tweets', 14 | localField: 'retweet_id', 15 | foreignField: '_id', 16 | as: 'retweet', 17 | }, 18 | }, 19 | { $unwind: { path: '$retweet', preserveNullAndEmptyArrays: true } }, 20 | { 21 | $lookup: { 22 | from: 'users', 23 | localField: 'retweet.author_id', 24 | foreignField: 'user_id', 25 | as: 'retweet.author', 26 | }, 27 | }, 28 | { $unwind: { path: '$retweet.author', preserveNullAndEmptyArrays: true } }, 29 | { 30 | $project: { 31 | _id: 1, 32 | author_id: 1, 33 | author: 1, 34 | content: 1, 35 | parent_id: 1, 36 | retweet_id: 1, 37 | child_tweet_id_list: 1, 38 | img_url_list: 1, 39 | child_tweet_number: { $size: '$child_tweet_id_list' }, 40 | retweet_user_id_list: 1, 41 | retweet_user_number: { $size: '$retweet_user_id_list' }, 42 | heart_user_id_list: 1, 43 | heart_user_number: { $size: '$heart_user_id_list' }, 44 | createAt: 1, 45 | 'retweet._id': 1, 46 | 'retweet.author_id': 1, 47 | 'retweet.author': 1, 48 | 'retweet.content': 1, 49 | 'retweet.parent_id': 1, 50 | 'retweet.retweet_id': 1, 51 | 'retweet.img_url_list': 1, 52 | 'retweet.child_tweet_id_list': 1, 53 | 'retweet.child_tweet_number': { 54 | $size: { $ifNull: ['$retweet.child_tweet_id_list', []] }, 55 | }, 56 | 'retweet.retweet_user_id_list': 1, 57 | 'retweet.retweet_user_number': { 58 | $size: { $ifNull: ['$retweet.retweet_user_id_list', []] }, 59 | }, 60 | 'retweet.heart_user_id_list': 1, 61 | 'retweet.heart_user_number': { 62 | $size: { $ifNull: ['$retweet.heart_user_id_list', []] }, 63 | }, 64 | 'retweet.createAt': 1, 65 | }, 66 | }, 67 | ]; 68 | 69 | export default commonReadCondition; 70 | -------------------------------------------------------------------------------- /be/src/services/tweet/deleteTweet.ts: -------------------------------------------------------------------------------- 1 | import { AuthenticationError } from 'apollo-server-express'; 2 | import { notificationModel, tweetModel, userModel } from '@models'; 3 | 4 | interface Auth { 5 | authUser: { id: string }; 6 | } 7 | 8 | interface Args { 9 | tweet_id?: string; 10 | } 11 | const deleteTweet = async (_: any, { tweet_id }: Args, { authUser }: Auth) => { 12 | if (!authUser) throw new AuthenticationError('not authenticated'); 13 | 14 | const userId = authUser.id; 15 | 16 | const willDeletedTweet = await tweetModel.findOne({ author_id: userId, _id: tweet_id }); 17 | 18 | const retweetId = willDeletedTweet?.get('retweet_id'); 19 | 20 | if (retweetId) 21 | await tweetModel.updateOne({ _id: retweetId }, { $pull: { retweet_user_id_list: userId } }); 22 | 23 | const parentId = willDeletedTweet?.get('parent_id'); 24 | 25 | if (parentId) 26 | await tweetModel.updateOne({ _id: parentId }, { $pull: { child_tweet_id_list: tweet_id } }); 27 | 28 | const heart_user_id_list = willDeletedTweet?.get('heart_user_id_list'); 29 | 30 | if (heart_user_id_list.length !== 0) 31 | await userModel.update( 32 | { user_id: { $in: heart_user_id_list } }, 33 | { $pull: { heart_tweet_id_list: tweet_id } }, 34 | ); 35 | 36 | await notificationModel.deleteMany({ tweet_id }); 37 | 38 | const { deletedCount } = await tweetModel.deleteOne({ author_id: userId, _id: tweet_id }); 39 | if (deletedCount === 0) { 40 | throw new Error('not deleted'); 41 | } 42 | return { response: true }; 43 | }; 44 | 45 | export default deleteTweet; 46 | -------------------------------------------------------------------------------- /be/src/services/tweet/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getFollowingTweetList, 3 | getUserTweetList, 4 | getUserAllTweetList, 5 | getDetailTweet, 6 | getChildTweetList, 7 | getHeartTweetList, 8 | getSearchedTweetList, 9 | getLatestTweetList, 10 | } from './getTweet'; 11 | import { addBasicTweet, addReplyTweet, addRetweet } from './addTweet'; 12 | import deleteTweet from './deleteTweet'; 13 | import { heartTweet, unheartTweet } from './modifyTweet'; 14 | 15 | export { 16 | getFollowingTweetList, 17 | getUserTweetList, 18 | getUserAllTweetList, 19 | addBasicTweet, 20 | addReplyTweet, 21 | addRetweet, 22 | deleteTweet, 23 | heartTweet, 24 | unheartTweet, 25 | getDetailTweet, 26 | getChildTweetList, 27 | getHeartTweetList, 28 | getSearchedTweetList, 29 | getLatestTweetList, 30 | }; 31 | -------------------------------------------------------------------------------- /be/src/services/tweet/modifyTweet.ts: -------------------------------------------------------------------------------- 1 | import { AuthenticationError } from 'apollo-server-express'; 2 | import { tweetModel, userModel } from '@models'; 3 | import { createNotification } from '@services/notification'; 4 | 5 | interface Auth { 6 | authUser: { id: string }; 7 | } 8 | 9 | interface Args { 10 | tweet_id: string; 11 | } 12 | 13 | const heartTweet = async (_: any, { tweet_id }: Args, { authUser }: Auth) => { 14 | if (!authUser) throw new AuthenticationError('not authenticated'); 15 | 16 | const userId = authUser.id; 17 | const tweet = await tweetModel.findOneAndUpdate( 18 | { _id: tweet_id }, 19 | { $addToSet: { heart_user_id_list: userId } }, 20 | { new: true }, 21 | ); 22 | 23 | await userModel.findOneAndUpdate( 24 | { user_id: userId }, 25 | { $addToSet: { heart_tweet_id_list: tweet_id } }, 26 | { new: true }, 27 | ); 28 | 29 | await createNotification({ 30 | userId: tweet?.get('author_id'), 31 | tweetId: tweet_id, 32 | type: 'heart', 33 | giverId: userId, 34 | }); 35 | return tweet; 36 | }; 37 | 38 | const unheartTweet = async (_: any, { tweet_id }: Args, { authUser }: Auth) => { 39 | if (!authUser) throw new AuthenticationError('not authenticated'); 40 | 41 | const userId = authUser.id; 42 | 43 | const tweet = await tweetModel.findOneAndUpdate( 44 | { _id: tweet_id }, 45 | { $pull: { heart_user_id_list: userId } }, 46 | { new: true }, 47 | ); 48 | await userModel.findOneAndUpdate( 49 | { user_id: userId }, 50 | { $pull: { heart_tweet_id_list: tweet_id } }, 51 | { new: true }, 52 | ); 53 | 54 | return tweet; 55 | }; 56 | 57 | export { heartTweet, unheartTweet }; 58 | -------------------------------------------------------------------------------- /be/src/services/upload/index.ts: -------------------------------------------------------------------------------- 1 | import fs, { createWriteStream, ReadStream } from 'fs'; 2 | 3 | interface File { 4 | file: { 5 | createReadStream(): ReadStream; 6 | filename: string; 7 | mimetype: string; 8 | encoding: string; 9 | }; 10 | } 11 | 12 | const imgUpload = async (_: any, { file: { file: imgFile } }: any) => { 13 | const { createReadStream, filename } = await imgFile; 14 | 15 | !fs.existsSync('uploads') && fs.mkdirSync('uploads'); 16 | 17 | const stream = createReadStream(); 18 | const newFilename = new Date().getTime().toString() + filename; 19 | 20 | await new Promise((resolve, reject) => { 21 | stream 22 | .pipe(createWriteStream(`uploads/${newFilename}`)) 23 | .on('error', reject) 24 | .on('finish', resolve); 25 | }); 26 | 27 | const IMG_URL = 28 | process.env.NODE_ENV === 'development' ? process.env.DEV_IMG_URL : process.env.PRO_IMG_URL; 29 | 30 | return { img_url: IMG_URL + newFilename }; 31 | }; 32 | 33 | export { imgUpload }; 34 | -------------------------------------------------------------------------------- /be/src/services/user/addUser.ts: -------------------------------------------------------------------------------- 1 | import { userModel } from '@models'; 2 | import getHashedPassword from '@libs/hash-password'; 3 | import { makeRandomName } from '@libs/utiltys'; 4 | 5 | interface UserInfo { 6 | user_id: string; 7 | name: string; 8 | github_id: number; 9 | password?: string; 10 | profile_img_url?: string; 11 | background_img_url?: string; 12 | } 13 | 14 | const checkUnique = async (userId: string): Promise => { 15 | const user = await userModel.findOne({ user_id: userId }); 16 | if (!user) return true; 17 | return false; 18 | }; 19 | 20 | const makeUniqueUserId = async (userId: string): Promise => { 21 | const isUnique = await checkUnique(userId); 22 | if (isUnique) return userId; 23 | return makeUniqueUserId(`${userId} ${makeRandomName(4)}`); 24 | }; 25 | 26 | const registerUser = async (userInfo: UserInfo) => { 27 | const uniqueUserId = await makeUniqueUserId(userInfo.user_id); 28 | const user = await userModel.create({ 29 | user_id: uniqueUserId, 30 | github_id: userInfo.github_id, 31 | name: userInfo.name ? userInfo.name : makeRandomName(10), 32 | password: userInfo.password, 33 | following_id_list: [], 34 | profile_img_url: userInfo.profile_img_url, 35 | background_img_url: 36 | 'https://images.homedepot-static.com/productImages/fc91cb23-b6db-4d32-b02a-f1ed61dd39a8/svn/folkstone-matte-formica-laminate-sheets-009271258408000-64_400_compressed.jpg', 37 | heart_tweet_id_list: [], 38 | }); 39 | return user; 40 | }; 41 | 42 | const createUser = async (_: any, args: UserInfo) => { 43 | if (!args.user_id || !args.password || !args.name) { 44 | throw Error('Not enough information'); 45 | } 46 | const isUnique = await checkUnique(args.user_id); 47 | if (!isUnique) throw Error('not unique user id'); 48 | 49 | args.password = await getHashedPassword(args.password); 50 | args.profile_img_url = 51 | 'https://abs.twimg.com/sticky/default_profile_images/default_profile_400x400.png'; 52 | args.background_img_url = 53 | 'https://images.homedepot-static.com/productImages/fc91cb23-b6db-4d32-b02a-f1ed61dd39a8/svn/folkstone-matte-formica-laminate-sheets-009271258408000-64_400_compressed.jpg'; 54 | await registerUser(args); 55 | 56 | return { response: true }; 57 | }; 58 | 59 | export { registerUser, createUser }; 60 | -------------------------------------------------------------------------------- /be/src/services/user/getUser.ts: -------------------------------------------------------------------------------- 1 | import { AuthenticationError } from 'apollo-server-express'; 2 | import { userModel, tweetModel } from '@models'; 3 | import { stringToObjectId } from '@libs/utiltys'; 4 | 5 | interface Auth { 6 | authUser: { id: string }; 7 | } 8 | 9 | interface Args { 10 | oldest_user_id: string; 11 | search_word: string; 12 | user_id: string; 13 | tweet_id: string; 14 | } 15 | 16 | const getNextUsersCondition = (oldest_user_id: string): Object => { 17 | return oldest_user_id ? { _id: { $lt: stringToObjectId(oldest_user_id) } } : {}; 18 | }; 19 | 20 | const getFollowingList = async (_: any, { user_id, oldest_user_id }: Args, { authUser }: Auth) => { 21 | if (!authUser) throw new AuthenticationError('not authenticated'); 22 | 23 | const nextUsersCondition = getNextUsersCondition(oldest_user_id); 24 | 25 | const userInfo = await userModel.findOne({ user_id }); 26 | 27 | const followingList: Document[] = await userModel.aggregate([ 28 | { 29 | $match: { 30 | $and: [{ user_id: { $in: userInfo?.get('following_id_list') } }, nextUsersCondition], 31 | }, 32 | }, 33 | { $sort: { _id: -1 } }, 34 | { $limit: 20 }, 35 | ]); 36 | 37 | return followingList; 38 | }; 39 | 40 | const getFollowerList = async (_: any, { user_id, oldest_user_id }: Args, { authUser }: Auth) => { 41 | if (!authUser) throw new AuthenticationError('not authenticated'); 42 | 43 | const nextUsersCondition = getNextUsersCondition(oldest_user_id); 44 | 45 | const followerList: Document[] = await userModel.aggregate([ 46 | { 47 | $match: { 48 | $and: [ 49 | { 50 | following_id_list: user_id, 51 | }, 52 | nextUsersCondition, 53 | ], 54 | }, 55 | }, 56 | { $sort: { _id: -1 } }, 57 | { $limit: 20 }, 58 | ]); 59 | 60 | return followerList; 61 | }; 62 | 63 | const getFollowerCount = async (_: any, { user_id }: Args, { authUser }: Auth) => { 64 | if (!authUser) throw new AuthenticationError('not authenticated'); 65 | 66 | const [followerCount]: Document[] = await userModel.aggregate([ 67 | { 68 | $match: { 69 | following_id_list: user_id, 70 | }, 71 | }, 72 | { $count: 'count' }, 73 | ]); 74 | return followerCount || { count: 0 }; 75 | }; 76 | 77 | const getHeartUserList = async (_: any, { tweet_id, oldest_user_id }: Args, { authUser }: Auth) => { 78 | if (!authUser) throw new AuthenticationError('not authenticated'); 79 | 80 | const nextUsersCondition = getNextUsersCondition(oldest_user_id); 81 | 82 | const tweet = await tweetModel.findOne({ _id: tweet_id }); 83 | 84 | const heartUserList: Document[] = await userModel.aggregate([ 85 | { 86 | $match: { 87 | $and: [{ user_id: { $in: tweet?.get('heart_user_id_list') } }, nextUsersCondition], 88 | }, 89 | }, 90 | { $limit: 20 }, 91 | ]); 92 | 93 | return heartUserList; 94 | }; 95 | 96 | const getRetweetUserList = async ( 97 | _: any, 98 | { tweet_id, oldest_user_id }: Args, 99 | { authUser }: Auth, 100 | ) => { 101 | if (!authUser) throw new AuthenticationError('not authenticated'); 102 | 103 | const nextUsersCondition = getNextUsersCondition(oldest_user_id); 104 | 105 | const tweet = await tweetModel.findOne({ _id: tweet_id }); 106 | 107 | const retweetUserList: Document[] = await userModel.aggregate([ 108 | { 109 | $match: { 110 | $and: [{ user_id: { $in: tweet?.get('retweet_user_id_list') } }, nextUsersCondition], 111 | }, 112 | }, 113 | { $limit: 20 }, 114 | ]); 115 | 116 | return retweetUserList; 117 | }; 118 | 119 | const getSearchedUserList = async ( 120 | _: any, 121 | { search_word, oldest_user_id }: Args, 122 | { authUser }: Auth, 123 | ) => { 124 | if (!authUser) throw new AuthenticationError('not authenticated'); 125 | 126 | const nextUsersCondition = getNextUsersCondition(oldest_user_id); 127 | 128 | const userList = await userModel 129 | .find({ $and: [{ user_id: { $regex: search_word } }, nextUsersCondition] }) 130 | .sort({ _id: -1 }) 131 | .limit(20); 132 | return userList; 133 | }; 134 | 135 | const getMyUserInfo = async (_: any, __: any, { authUser }: Auth) => { 136 | if (!authUser) throw new AuthenticationError('not authenticated'); 137 | 138 | const [userInfo] = await userModel.find({ user_id: authUser.id }); 139 | return userInfo; 140 | }; 141 | 142 | const getUserInfo = async (_: any, { user_id }: Args, { authUser }: Auth) => { 143 | if (!authUser) throw new AuthenticationError('not authenticated'); 144 | 145 | const [userInfo] = await userModel.find({ user_id }); 146 | return userInfo; 147 | }; 148 | 149 | export { 150 | getFollowerList, 151 | getFollowingList, 152 | getHeartUserList, 153 | getRetweetUserList, 154 | getSearchedUserList, 155 | getMyUserInfo, 156 | getUserInfo, 157 | getFollowerCount, 158 | }; 159 | -------------------------------------------------------------------------------- /be/src/services/user/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getFollowerList, 3 | getFollowingList, 4 | getHeartUserList, 5 | getRetweetUserList, 6 | getSearchedUserList, 7 | getUserInfo, 8 | getMyUserInfo, 9 | getFollowerCount, 10 | } from './getUser'; 11 | import { 12 | followUser, 13 | unfollowUser, 14 | updateUserInfo, 15 | updateUserComment, 16 | updateUserProfileImg, 17 | updateUserBackgroundImg, 18 | } from './modifyUser'; 19 | import { createUser } from './addUser'; 20 | 21 | export { 22 | getFollowerList, 23 | getFollowingList, 24 | getHeartUserList, 25 | getRetweetUserList, 26 | getSearchedUserList, 27 | getMyUserInfo, 28 | getUserInfo, 29 | getFollowerCount, 30 | followUser, 31 | unfollowUser, 32 | createUser, 33 | updateUserInfo, 34 | updateUserComment, 35 | updateUserProfileImg, 36 | updateUserBackgroundImg, 37 | }; 38 | -------------------------------------------------------------------------------- /be/src/services/user/modifyUser.ts: -------------------------------------------------------------------------------- 1 | import { AuthenticationError } from 'apollo-server-express'; 2 | import { userModel } from '@models'; 3 | import { createNotification } from '@services/notification'; 4 | 5 | interface Auth { 6 | authUser: { id: string }; 7 | } 8 | 9 | const followUser = async ( 10 | _: any, 11 | { follow_user_id }: { follow_user_id: string }, 12 | { authUser }: Auth, 13 | ) => { 14 | if (!authUser) throw new AuthenticationError('not authenticated'); 15 | 16 | const userId = authUser.id; 17 | 18 | if (follow_user_id === userId) throw new Error('not allow follow yourself'); 19 | 20 | const user = await userModel.findOneAndUpdate( 21 | { user_id: userId }, 22 | { $addToSet: { following_id_list: follow_user_id } }, 23 | { new: true }, 24 | ); 25 | await createNotification({ 26 | userId: follow_user_id, 27 | giverId: userId, 28 | type: 'follow', 29 | }); 30 | return user; 31 | }; 32 | 33 | const unfollowUser = async ( 34 | _: any, 35 | { unfollow_user_id }: { unfollow_user_id: string }, 36 | { authUser }: Auth, 37 | ) => { 38 | if (!authUser) throw new AuthenticationError('not authenticated'); 39 | 40 | const userId = authUser.id; 41 | 42 | const user = await userModel.findOneAndUpdate( 43 | { user_id: userId }, 44 | { $pull: { following_id_list: unfollow_user_id } }, 45 | { new: true }, 46 | ); 47 | return user; 48 | }; 49 | 50 | const updateUserInfo = async ( 51 | _: any, 52 | { name, comment }: { name: string; comment: string }, 53 | { authUser }: Auth, 54 | ) => { 55 | if (!authUser) throw new AuthenticationError('not authenticated'); 56 | 57 | const userId = authUser.id; 58 | await userModel.update({ user_id: userId }, { $set: { name, comment } }); 59 | return { response: true }; 60 | }; 61 | 62 | const updateUserComment = async (_: any, { comment }: { comment: string }, { authUser }: Auth) => { 63 | if (!authUser) throw new AuthenticationError('not authenticated'); 64 | 65 | const userId = authUser.id; 66 | await userModel.update({ user_id: userId }, { $set: { comment } }); 67 | return { response: true }; 68 | }; 69 | 70 | const updateUserProfileImg = async ( 71 | _: any, 72 | { profile_img_url }: { profile_img_url: string }, 73 | { authUser }: Auth, 74 | ) => { 75 | if (!authUser) throw new AuthenticationError('not authenticated'); 76 | 77 | const userId = authUser.id; 78 | await userModel.update({ user_id: userId }, { $set: { profile_img_url } }); 79 | return { response: true }; 80 | }; 81 | 82 | const updateUserBackgroundImg = async ( 83 | _: any, 84 | { background_img_url }: { background_img_url: string }, 85 | { authUser }: Auth, 86 | ) => { 87 | if (!authUser) throw new AuthenticationError('not authenticated'); 88 | 89 | const userId = authUser.id; 90 | await userModel.update({ user_id: userId }, { $set: { background_img_url } }); 91 | return { response: true }; 92 | }; 93 | 94 | export { 95 | followUser, 96 | unfollowUser, 97 | updateUserInfo, 98 | updateUserComment, 99 | updateUserProfileImg, 100 | updateUserBackgroundImg, 101 | }; 102 | -------------------------------------------------------------------------------- /fe/.env.example: -------------------------------------------------------------------------------- 1 | DEV_API_SERVER_URL = 2 | DEV_GITHUB_LOGIN_URL = 3 | 4 | PRO_API_SERVER_URL = 5 | PRO_GITHUB_LOGIN_URL = 6 | -------------------------------------------------------------------------------- /fe/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": ["airbnb", "prettier"], 4 | "env": { 5 | "browser": true, 6 | "node": true 7 | }, 8 | "plugins": ["@typescript-eslint", "react-hooks", "prettier"], 9 | "rules": { 10 | "prettier/prettier": ["error", { "endOfLine": "auto" }], 11 | "no-console": "off", 12 | "no-use-before-define": 0, 13 | "react/jsx-filename-extension": 0, 14 | "import/extensions": 0, 15 | "import/no-unresolved": 0, 16 | "react/prop-types": 0, 17 | "no-unused-vars": 0, 18 | "camelcase": 0, 19 | "no-underscore-dangle": 0, 20 | "jsx-a11y/anchor-is-valid": 0, 21 | "react/no-array-index-key": 0 22 | } 23 | } -------------------------------------------------------------------------------- /fe/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/react 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=react 4 | 5 | ### react ### 6 | .DS_* 7 | *.log 8 | logs 9 | **/*.backup.* 10 | **/*.back.* 11 | 12 | next-env.d.ts 13 | node_modules 14 | bower_components 15 | 16 | *.sublime* 17 | .next 18 | psd 19 | thumb 20 | sketch 21 | 22 | .env 23 | 24 | # End of https://www.toptal.com/developers/gitignore/api/react -------------------------------------------------------------------------------- /fe/.storybook/main.js: -------------------------------------------------------------------------------- 1 | const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin'); 2 | 3 | module.exports = { 4 | stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], 5 | addons: [ 6 | '@storybook/addon-links', 7 | '@storybook/addon-essentials', 8 | '@storybook/addon-actions', 9 | '@storybook/addon-knobs', 10 | ], 11 | webpackFinal: async (config) => { 12 | config.resolve.plugins.push(new TsconfigPathsPlugin({})); 13 | return config; 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /fe/README.md: -------------------------------------------------------------------------------- 1 | ## Twitter Clone Service 🐤 2 | 3 |
4 | 5 | 트위터 로고
6 | 7 |
8 | 9 | Bwitter는 실시간으로 Follow 및 Follower와 소통이 가능한 Twitter를 Clone한 프로젝트 입니다. 10 | 11 | ## 실행 스크립트 12 | 13 | ``` 14 | npm i 15 | 16 | npm run dev #development 17 | ``` 18 | 19 | ## 폴더 구조 20 | 21 | ``` 22 | 📁fe 23 | ├── 📄 README.md - 리드미 파일 24 | │ 25 | ├── 📁.storybook/ - 스토리북 설정 폴더 26 | │ 27 | ├── 📁src/ - 프론트 소스 폴더 28 | │ ├── 📁components/ - 프론트 컴포넌트 폴더 29 | │ │ ├── 📁atoms/ - atoms 컴포넌트 폴더 30 | │ │ ├── 📁molecules/ - molecules 컴포넌트 폴더 31 | │ │ └── 📁organisms/ - organisms 컴포넌트 폴더 32 | │ │ 33 | │ ├── 📁graphql/ - graphQL 쿼리 폴더 34 | │ │ ├── 📁auth/ - auth에 관한 graphQL 쿼리 폴더 35 | │ │ ├── 📁custom/ - 두가지 이상의 쿼리문을 합친 graphQL 쿼리 폴더 36 | │ │ ├── 📁image/ - image에 관한 graphQL 쿼리 폴더 37 | │ │ ├── 📁notifications/ - notifications에 관한 graphQL 쿼리 폴더 38 | │ │ ├── 📁tweet/ - tweet에 관한 graphQL 쿼리 폴더 39 | │ │ └── 📁user/ - user에 관한 graphQL 쿼리 폴더 40 | │ │ 41 | │ ├── 📁hook/ - custom hook 폴더 42 | │ │ 43 | │ ├── 📁libs/ - 직접 구현한 library 폴더 44 | │ │ 45 | │ ├── 📁page/ - 페이지 폴더 46 | │ │ ├── 📁[userId]/ - 사용자 페이지 폴더 47 | │ │ │ └── 📁follow/ - 사용자의 follower / following 페이지 폴더 48 | │ │ ├── 📁callback/ - github 로그인시 콜백 페이지 폴더 49 | │ │ ├── 📁explore/ - 검색 페이지 폴더 50 | │ │ ├── 📁login/ - 로그인 페이지 폴더 51 | │ │ ├── 📁notifications/ - 사용자 알림 페이지 폴더 52 | │ │ ├── 📁status/ - 글 상세 페이지 폴더 53 | │ │ ├── 📄_app.tsx - 모든 페이지를 담고 있는 최상위 컴포넌트 54 | │ │ ├── 📄_document.tsx - 시작점이 되는 index.html같은 파일 55 | │ │ └── 📄index.tsx - 홈 페이지 파일 56 | │ │ 57 | │ ├── 📁styles/ - 프로젝트 전체에 적용되는 default css 파일 58 | │ │ 59 | │ └── 📁types/ - 공통으로 사용되는 타입을 정의해 놓은 폴더 60 | ``` 61 | -------------------------------------------------------------------------------- /fe/next.config.js: -------------------------------------------------------------------------------- 1 | const withSourceMaps = require('@zeit/next-source-maps'); 2 | const Dotenv = require('dotenv-webpack'); 3 | 4 | const nextConfig = withSourceMaps({ 5 | webpack: (config, options) => { 6 | config.module.rules.push({ 7 | test: /\.(graphql|gql)$/, 8 | exclude: /node_modules/, 9 | loader: 'graphql-tag/loader', 10 | }); 11 | config.plugins.push(new Dotenv({ silent: true })); 12 | 13 | return config; 14 | }, 15 | }); 16 | 17 | module.exports = nextConfig; 18 | -------------------------------------------------------------------------------- /fe/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "twitter", 3 | "version": "0.0.0", 4 | "description": "this is twitter-web", 5 | "scripts": { 6 | "dev": "cross-env NODE_ENV=development next", 7 | "build": "cross-env NODE_ENV=production next build", 8 | "prod": "cross-env NODE_ENV=production next start", 9 | "prod-dist": "pm2 start npm --name 'bwitter-web-dist' -- run prod", 10 | "storybook": "start-storybook -p 6006 -c .storybook" 11 | }, 12 | "author": "", 13 | "license": "ISC", 14 | "dependencies": { 15 | "@apollo/client": "^3.2.9", 16 | "@material-ui/core": "^4.11.0", 17 | "@storybook/addon-knobs": "^6.1.2", 18 | "@zeit/next-source-maps": "0.0.3", 19 | "apollo-upload-client": "^14.1.3", 20 | "cookies": "^0.8.0", 21 | "dotenv-webpack": "^6.0.0", 22 | "graphql": "^15.4.0", 23 | "graphql-tag": "^2.11.0", 24 | "javascript-time-ago": "^2.3.3", 25 | "next": "^10.0.2", 26 | "react": "^17.0.1", 27 | "react-dom": "^17.0.1", 28 | "react-hot-loader": "^4.13.0", 29 | "react-markdown": "^5.0.3", 30 | "styled-components": "^5.2.1", 31 | "tsconfig-paths-webpack-plugin": "^3.3.0", 32 | "typescript": "^4.0.5" 33 | }, 34 | "devDependencies": { 35 | "@material-ui/icons": "^4.9.1", 36 | "@storybook/addon-actions": "^6.0.28", 37 | "@storybook/addon-backgrounds": "^6.0.28", 38 | "@storybook/addon-essentials": "^6.0.28", 39 | "@storybook/addon-links": "^6.0.28", 40 | "@storybook/node-logger": "^6.0.28", 41 | "@storybook/react": "^6.0.28", 42 | "@types/apollo-upload-client": "^14.1.0", 43 | "@types/cookies": "^0.7.5", 44 | "@types/javascript-time-ago": "^2.0.1", 45 | "@types/react": "^16.9.56", 46 | "@types/react-dom": "^16.9.9", 47 | "@types/react-router-dom": "^5.1.6", 48 | "@types/styled-components": "^5.1.5", 49 | "@typescript-eslint/eslint-plugin": "^4.8.1", 50 | "@typescript-eslint/parser": "^4.8.1", 51 | "babel-plugin-styled-components": "^1.12.0", 52 | "cross-env": "^7.0.3", 53 | "eslint": "^7.2.0", 54 | "eslint-config-airbnb": "^18.2.1", 55 | "eslint-config-prettier": "^6.15.0", 56 | "eslint-plugin-import": "^2.22.1", 57 | "eslint-plugin-jsx-a11y": "^6.4.1", 58 | "eslint-plugin-prettier": "^3.1.4", 59 | "eslint-plugin-react": "^7.21.5", 60 | "eslint-plugin-react-hooks": "^4.0.0", 61 | "pm2": "^4.5.0" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /fe/src/components/atoms/Icons/Comment.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | import { SvgIcon } from '@material-ui/core'; 3 | import styled from 'styled-components'; 4 | 5 | interface StyledProps { 6 | width?: string; 7 | height?: string; 8 | } 9 | 10 | const SvgContainer = styled(SvgIcon)` 11 | width: ${(props) => props.width} !important; 12 | height: ${(props) => props.height} !important; 13 | `; 14 | 15 | const CommentIcon: FunctionComponent = ({ width, height }) => ( 16 | 17 | 18 | 19 | ); 20 | 21 | export default CommentIcon; 22 | -------------------------------------------------------------------------------- /fe/src/components/atoms/Icons/Explore.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | import { SvgIcon } from '@material-ui/core'; 3 | import styled from 'styled-components'; 4 | 5 | interface StyledProps { 6 | width?: string; 7 | height?: string; 8 | } 9 | 10 | const SvgContainer = styled(SvgIcon)` 11 | width: ${(props) => props.width} !important; 12 | height: ${(props) => props.height} !important; 13 | `; 14 | 15 | const ExploreIcon: FunctionComponent = ({ width, height }) => ( 16 | 17 | 18 | 19 | ); 20 | 21 | export default ExploreIcon; 22 | -------------------------------------------------------------------------------- /fe/src/components/atoms/Icons/FullHeart.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | import { SvgIcon } from '@material-ui/core'; 3 | import styled from 'styled-components'; 4 | 5 | interface StyledProps { 6 | width?: string; 7 | height?: string; 8 | } 9 | 10 | const SvgContainer = styled(SvgIcon)` 11 | width: ${(props) => props.width} !important; 12 | height: ${(props) => props.height} !important; 13 | fill: #cc3366 !important; 14 | `; 15 | 16 | const FullHeartIcon: FunctionComponent = ({ width, height }) => ( 17 | 18 | 19 | 20 | ); 21 | 22 | export default FullHeartIcon; 23 | -------------------------------------------------------------------------------- /fe/src/components/atoms/Icons/Heart.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | import { SvgIcon } from '@material-ui/core'; 3 | import styled from 'styled-components'; 4 | 5 | interface StyledProps { 6 | width?: string; 7 | height?: string; 8 | } 9 | 10 | const SvgContainer = styled(SvgIcon)` 11 | width: ${(props) => props.width} !important; 12 | height: ${(props) => props.height} !important; 13 | `; 14 | 15 | const HeartIcon: FunctionComponent = ({ width, height }) => ( 16 | 17 | 18 | 19 | ); 20 | 21 | export default HeartIcon; 22 | -------------------------------------------------------------------------------- /fe/src/components/atoms/Icons/Home.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | import { SvgIcon } from '@material-ui/core'; 3 | import styled from 'styled-components'; 4 | 5 | interface StyledProps { 6 | width?: string; 7 | height?: string; 8 | } 9 | 10 | const SvgContainer = styled(SvgIcon)` 11 | width: ${(props) => props.width} !important; 12 | height: ${(props) => props.height} !important; 13 | `; 14 | 15 | const HomeIcon: FunctionComponent = ({ width, height }) => ( 16 | 17 | 18 | 19 | 20 | ); 21 | 22 | export default HomeIcon; 23 | -------------------------------------------------------------------------------- /fe/src/components/atoms/Icons/Notifications.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | import { SvgIcon } from '@material-ui/core'; 3 | import styled from 'styled-components'; 4 | 5 | interface StyledProps { 6 | width?: string; 7 | height?: string; 8 | } 9 | 10 | const SvgContainer = styled(SvgIcon)` 11 | width: ${(props) => props.width} !important; 12 | height: ${(props) => props.height} !important; 13 | `; 14 | 15 | const NotificationsIcon: FunctionComponent = ({ width, height }) => ( 16 | 17 | 18 | 19 | ); 20 | 21 | export default NotificationsIcon; 22 | -------------------------------------------------------------------------------- /fe/src/components/atoms/Icons/Picture.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | import { SvgIcon } from '@material-ui/core'; 3 | import styled from 'styled-components'; 4 | 5 | interface StyledProps { 6 | width?: string; 7 | height?: string; 8 | } 9 | 10 | const SvgContainer = styled(SvgIcon)` 11 | width: ${(props) => props.width} !important; 12 | height: ${(props) => props.height} !important; 13 | `; 14 | 15 | const PictureIcon: FunctionComponent = ({ width, height }) => ( 16 | 17 | 18 | 19 | ); 20 | 21 | export default PictureIcon; 22 | -------------------------------------------------------------------------------- /fe/src/components/atoms/Icons/Profiles.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | import { SvgIcon } from '@material-ui/core'; 3 | import styled from 'styled-components'; 4 | 5 | interface StyledProps { 6 | width?: string; 7 | height?: string; 8 | } 9 | 10 | const SvgContainer = styled(SvgIcon)` 11 | width: ${(props) => props.width} !important; 12 | height: ${(props) => props.height} !important; 13 | `; 14 | 15 | const ProfilesIcon: FunctionComponent = ({ width, height }) => ( 16 | 17 | 18 | 19 | ); 20 | 21 | export default ProfilesIcon; 22 | -------------------------------------------------------------------------------- /fe/src/components/atoms/Icons/Retweet.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | import { SvgIcon } from '@material-ui/core'; 3 | import styled from 'styled-components'; 4 | 5 | interface StyledProps { 6 | width?: string; 7 | height?: string; 8 | } 9 | 10 | const SvgContainer = styled(SvgIcon)` 11 | width: ${(props) => props.width} !important; 12 | height: ${(props) => props.height} !important; 13 | `; 14 | 15 | const RetweetIcon: FunctionComponent = ({ width, height }) => ( 16 | 17 | 18 | 19 | ); 20 | 21 | export default RetweetIcon; 22 | -------------------------------------------------------------------------------- /fe/src/components/atoms/Icons/Search.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | import { SvgIcon } from '@material-ui/core'; 3 | import styled from 'styled-components'; 4 | 5 | interface StyledProps { 6 | width?: string; 7 | height?: string; 8 | } 9 | 10 | const SvgContainer = styled(SvgIcon)` 11 | width: ${(props) => props.width} !important; 12 | height: ${(props) => props.height} !important; 13 | `; 14 | 15 | const SearchIcon: FunctionComponent = ({ width, height }) => ( 16 | 17 | 18 | 19 | ); 20 | 21 | export default SearchIcon; 22 | -------------------------------------------------------------------------------- /fe/src/components/atoms/Icons/Twitter.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | import styled from 'styled-components'; 3 | import { SvgIcon } from '@material-ui/core'; 4 | 5 | interface StyledProps { 6 | htmlColor?: string; 7 | width?: string; 8 | height?: string; 9 | } 10 | 11 | const SvgContainer = styled(SvgIcon)` 12 | color: ${(props) => props.htmlColor}; 13 | width: ${(props) => props.width} !important; 14 | height: ${(props) => props.height} !important; 15 | `; 16 | 17 | const TwitterIcon: FunctionComponent = ({ 18 | htmlColor = '#7accfe', 19 | width = '27px', 20 | height = '27px', 21 | }) => { 22 | return ( 23 | 24 | 25 | 26 | ); 27 | }; 28 | 29 | export default TwitterIcon; 30 | -------------------------------------------------------------------------------- /fe/src/components/atoms/Icons/X.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | import { SvgIcon } from '@material-ui/core'; 3 | import styled from 'styled-components'; 4 | 5 | interface StyledProps { 6 | width?: string; 7 | height?: string; 8 | } 9 | 10 | const SvgContainer = styled(SvgIcon)` 11 | width: ${(props) => props.width} !important; 12 | height: ${(props) => props.height} !important; 13 | `; 14 | 15 | const XIcon: FunctionComponent = ({ width, height }) => ( 16 | 17 | 18 | 19 | 20 | ); 21 | 22 | export default XIcon; 23 | -------------------------------------------------------------------------------- /fe/src/components/atoms/Icons/index.ts: -------------------------------------------------------------------------------- 1 | import Explore from './Explore'; 2 | import Home from './Home'; 3 | import Notifications from './Notifications'; 4 | import Profiles from './Profiles'; 5 | import Search from './Search'; 6 | import Twitter from './Twitter'; 7 | import Picture from './Picture'; 8 | import Heart from './Heart'; 9 | import Comment from './Comment'; 10 | import Retweet from './Retweet'; 11 | import FullHeart from './FullHeart'; 12 | import X from './X'; 13 | 14 | export { 15 | Explore, 16 | Home, 17 | Notifications, 18 | Profiles, 19 | Search, 20 | Twitter, 21 | Picture, 22 | Heart, 23 | Comment, 24 | Retweet, 25 | FullHeart, 26 | X, 27 | }; 28 | -------------------------------------------------------------------------------- /fe/src/components/atoms/Input/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | import { InputProps, TextField } from '@material-ui/core'; 3 | 4 | interface Props { 5 | onChange: (e: React.SyntheticEvent) => void; 6 | onKeyDown?: (e: React.KeyboardEvent) => void; 7 | placeholder?: string; 8 | type?: string; 9 | value: string; 10 | disableUnderline?: boolean; 11 | } 12 | 13 | const Input: FunctionComponent = ({ 14 | placeholder = '', 15 | type = 'text', 16 | onChange, 17 | onKeyDown, 18 | value = '', 19 | disableUnderline = true, 20 | }) => ( 21 | 29 | ); 30 | 31 | export default Input; 32 | -------------------------------------------------------------------------------- /fe/src/components/atoms/Input/input.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import Input from './index'; 3 | 4 | export default { 5 | title: 'Atoms/Input', 6 | component: Input, 7 | }; 8 | 9 | export const Default = () => { 10 | const [value, setValue] = useState(''); 11 | const placeholder = '입력!'; 12 | const type = 'text'; 13 | const onChange = (e: React.SyntheticEvent) => { 14 | const target = e.target as HTMLInputElement; 15 | setValue(target.value); 16 | }; 17 | return ; 18 | }; 19 | 20 | export const noUnderLineInput = () => { 21 | const [value, setValue] = useState(''); 22 | const placeholder = '입력!'; 23 | const type = 'text'; 24 | const onChange = (e: React.SyntheticEvent) => { 25 | const target = e.target as HTMLInputElement; 26 | setValue(target.value); 27 | }; 28 | return ; 29 | }; 30 | -------------------------------------------------------------------------------- /fe/src/components/atoms/NoResult/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | import Container from './styled'; 3 | 4 | interface Props { 5 | value: string; 6 | start: string; 7 | end: string; 8 | } 9 | 10 | const NoResult: FunctionComponent = ({ start, value, end }) => { 11 | const string = `${start} ${value} ${end}`; 12 | return {string}; 13 | }; 14 | 15 | export default NoResult; 16 | -------------------------------------------------------------------------------- /fe/src/components/atoms/NoResult/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Container = styled.div` 4 | text-align: center; 5 | font-weight: 600px; 6 | font-size: 25px; 7 | `; 8 | 9 | export default Container; 10 | -------------------------------------------------------------------------------- /fe/src/components/atoms/ProfileImg/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | import Profile from './styled'; 3 | 4 | interface Props { 5 | img?: string; 6 | size?: number; 7 | onClick?: () => void; 8 | } 9 | 10 | const ProfileImg: FunctionComponent = ({ 11 | img = 'https://upload.wikimedia.org/wikipedia/commons/9/99/Sample_User_Icon.png', 12 | onClick = () => {}, 13 | size = 50, 14 | }) => ( 15 | 21 | ); 22 | 23 | export default ProfileImg; 24 | -------------------------------------------------------------------------------- /fe/src/components/atoms/Text/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | import { StyledText, StyledTitleText, StyledSubText } from './styled'; 3 | 4 | interface Props { 5 | className?: string; 6 | value: string; 7 | color?: string; 8 | size?: string; 9 | weight?: number; 10 | styled?: 'root' | 'title' | 'sub'; 11 | } 12 | 13 | const Text: FunctionComponent = ({ 14 | className, 15 | value, 16 | color = '#000', 17 | size = '15px', 18 | weight = 400, 19 | styled = 'root', 20 | }) => { 21 | switch (styled) { 22 | case 'root': 23 | default: 24 | return ( 25 | 30 | {value} 31 | 32 | ); 33 | case 'title': 34 | return ( 35 | 36 | {value} 37 | 38 | ); 39 | case 'sub': 40 | return ( 41 | 42 | {value} 43 | 44 | ); 45 | } 46 | }; 47 | 48 | export default Text; 49 | -------------------------------------------------------------------------------- /fe/src/components/atoms/Text/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const StyledText = styled.span` 4 | color: ${(props) => props.color}; 5 | font-size: ${(props) => props.theme.fontSize}; 6 | font-weight: ${(props) => props.theme.fontWeight}; 7 | `; 8 | const StyledTitleText = styled.span` 9 | color: #000; 10 | font-size: ${(props) => props.theme.fontSize}; 11 | font-weight: 700; 12 | display: inline-block; 13 | margin-right: 5px; 14 | `; 15 | 16 | const StyledSubText = styled.span` 17 | color: rgb(91, 112, 131); 18 | font-size: ${(props) => props.theme.fontSize}; 19 | font-weight: 400; 20 | display: inline-block; 21 | `; 22 | 23 | export { StyledText, StyledTitleText, StyledSubText }; 24 | -------------------------------------------------------------------------------- /fe/src/components/atoms/TextArea/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | import Text from './styled'; 3 | 4 | interface Props { 5 | onChange?: (e: React.SyntheticEvent) => void; 6 | placeholder?: string; 7 | value?: string; 8 | } 9 | 10 | const TextArea: FunctionComponent = ({ 11 | onChange = () => {}, 12 | placeholder = '', 13 | value = '', 14 | }) => { 15 | return ; 16 | }; 17 | 18 | export default TextArea; 19 | -------------------------------------------------------------------------------- /fe/src/components/atoms/index.ts: -------------------------------------------------------------------------------- 1 | import Form from './Form'; 2 | import { 3 | Explore, 4 | Home, 5 | Notifications, 6 | Profiles, 7 | Search, 8 | Twitter, 9 | Picture, 10 | Heart, 11 | Comment, 12 | Retweet, 13 | FullHeart, 14 | X, 15 | } from './Icons'; 16 | import Input from './Input'; 17 | import Label from './Label'; 18 | import ProfileImg from './ProfileImg'; 19 | import Text from './Text'; 20 | import TextArea from './TextArea'; 21 | import NoResult from './NoResult'; 22 | 23 | export { 24 | Form, 25 | Explore, 26 | Home, 27 | Notifications, 28 | Profiles, 29 | Search, 30 | Twitter, 31 | Picture, 32 | Heart, 33 | Comment, 34 | Retweet, 35 | FullHeart, 36 | X, 37 | Input, 38 | Label, 39 | ProfileImg, 40 | Text, 41 | TextArea, 42 | NoResult, 43 | }; 44 | -------------------------------------------------------------------------------- /fe/src/components/molecules/Button/button.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { text } from '@storybook/addon-knobs'; 3 | import { Home, Explore, Twitter, Notifications, Profiles } from '@atoms'; 4 | import Button from './index'; 5 | 6 | export default { 7 | title: 'Molecules/Button', 8 | component: Button, 9 | }; 10 | 11 | export const Default = () => { 12 | const content = text('text', 'Tweet'); 13 | const color = 'primary'; 14 | const variant = 'contained'; 15 | const borderRadius = 50; 16 | const width = '50%'; 17 | 18 | return ( 19 |