├── .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 | 
11 | 
12 | 
13 | 
14 | 
15 | 
16 |
17 |
18 | [](https://github.com/boostcamp-2020/Project10-twitter/issues)
19 | [](https://github.com/boostcamp-2020/Project10-twitter/issues)
20 | [](https://github.com/boostcamp-2020/Project10-twitter/issues)
21 | [](https://github.com/boostcamp-2020/Project10-twitter/issues)
22 |
23 |
24 |
25 | ### 🔨 Feature 기능
26 |
27 | 
28 |
29 |
30 |
31 | ### 실행 화면
32 |
33 | 
34 |
35 | ### 팀원 소개 🌸
36 | ||
||
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 |
26 | );
27 | };
28 |
29 | export const HomeButtonWithText = () => {
30 | const content = text('text', 'Home');
31 | return ;
32 | };
33 |
34 | export const ExploreButtonWithText = () => {
35 | const content = text('text', 'Explore');
36 | return ;
37 | };
38 |
39 | export const NotificationsButtonWithText = () => {
40 | const content = text('text', 'Notifications');
41 | return ;
42 | };
43 |
44 | export const ProfilesButtonWithText = () => {
45 | const content = text('text', 'Profiles');
46 | return ;
47 | };
48 |
49 | export const TwitterButtonWithText = () => {
50 | const content = text('text', 'Twitter');
51 | return ;
52 | };
53 |
54 | export const LoginButton = () => {
55 | const content = text('text', '로그인');
56 | const color = 'primary';
57 | const variant = 'contained';
58 | const borderRadius = 50;
59 |
60 | return ;
61 | };
62 |
--------------------------------------------------------------------------------
/fe/src/components/molecules/Button/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent, ReactNode } from 'react';
2 | import { Button as MaterialButton } from '@material-ui/core';
3 |
4 | interface Props {
5 | className?: string;
6 | color?: 'primary' | 'secondary' | 'inherit' | 'default' | undefined;
7 | variant?: 'contained' | 'text' | 'outlined' | undefined;
8 | text: string | number;
9 | onClick?: (() => void) | (() => Promise);
10 | icon?: ReactNode;
11 | isStart?: true;
12 | borderRadius?: number;
13 | width?: string;
14 | disabled?: boolean;
15 | }
16 |
17 | const Button: FunctionComponent = ({
18 | className,
19 | color = undefined,
20 | variant = undefined,
21 | text,
22 | onClick,
23 | icon = undefined,
24 | isStart = true,
25 | borderRadius = 15,
26 | width = '',
27 | disabled = false,
28 | }) =>
29 | isStart ? (
30 |
39 | {text}
40 |
41 | ) : (
42 |
50 | {text}
51 |
52 | );
53 |
54 | export default Button;
55 |
--------------------------------------------------------------------------------
/fe/src/components/molecules/ComponentLoading/Point.ts:
--------------------------------------------------------------------------------
1 | export default class Point {
2 | x: number;
3 |
4 | y: number;
5 |
6 | fixedY: number;
7 |
8 | speed: number = 0.04;
9 |
10 | cur: number;
11 |
12 | max: number;
13 |
14 | constructor(index: number, x: number, y: number) {
15 | this.x = x;
16 | this.y = y;
17 | this.fixedY = y - 100;
18 | this.cur = index;
19 | this.max = Math.random() * 100 + 60;
20 | }
21 |
22 | update() {
23 | this.cur += this.speed;
24 | this.y = this.fixedY + Math.sin(this.cur) * this.max;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/fe/src/components/molecules/ComponentLoading/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from 'react';
2 | import { StyledDiv, LoadingCanvas, Bird } from './styled';
3 |
4 | const ComponentLoading: React.FC = () => {
5 | const canvasRef = useRef(null);
6 |
7 | useEffect(() => {
8 | if (canvasRef.current) {
9 | const canvas: HTMLCanvasElement = canvasRef.current;
10 | const ctx: CanvasRenderingContext2D = canvas.getContext('2d') as CanvasRenderingContext2D;
11 | canvas.width = canvas.clientWidth;
12 | canvas.height = canvas.clientHeight;
13 |
14 | const animate = () => {
15 | ctx.clearRect(0, 0, canvas.width, canvas.height);
16 | requestAnimationFrame(animate);
17 | };
18 |
19 | animate();
20 | }
21 | }, []);
22 |
23 | return (
24 |
25 |
26 |
27 |
28 | );
29 | };
30 |
31 | export default ComponentLoading;
32 |
--------------------------------------------------------------------------------
/fe/src/components/molecules/ComponentLoading/styled.ts:
--------------------------------------------------------------------------------
1 | import styled, { keyframes } from 'styled-components';
2 |
3 | const StyledDiv = styled.div`
4 | width: 100vw;
5 | height: 100vh;
6 | z-index: 11;
7 | opacity: 0.5;
8 | overflow: hidden;
9 | `;
10 |
11 | const slide = keyframes`
12 | from {
13 | transform: translate(0, 0);
14 | }
15 | to {
16 | transform: translate(0, -10rem);
17 | }
18 | `;
19 |
20 | const LoadingCanvas = styled.canvas`
21 | position: relative;
22 | width: 100%;
23 | height: calc(100% + 50rem);
24 | animation: ${slide} 3s ease-in-out infinite alternate;
25 | `;
26 |
27 | const fly = keyframes`
28 | 0% {
29 | top: 20%;
30 | }
31 | 25% {
32 | top: 10%;
33 | }
34 | 50% {
35 | top: 20%;
36 | }
37 | 75% {
38 | top: 10%;
39 | }
40 | 100% {
41 | top: 20%;
42 | }
43 | `;
44 |
45 | const Bird = styled.img`
46 | position: absolute;
47 | width: 10rem;
48 | height: 10rem;
49 | top: 50%;
50 | left: 50%;
51 | transform: translate(-50%, -50%);
52 | z-index: 20;
53 | animation: ${fly} 5s infinite linear;
54 | `;
55 |
56 | export { StyledDiv, LoadingCanvas, Bird };
57 |
--------------------------------------------------------------------------------
/fe/src/components/molecules/Footer/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent } from 'react';
2 | import { Text } from '@atoms';
3 | import Container from './styled';
4 |
5 | const Footer: FunctionComponent = () => {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | );
20 | };
21 |
22 | export default Footer;
23 |
--------------------------------------------------------------------------------
/fe/src/components/molecules/Footer/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const Container = styled.div`
4 | z-index: 1;
5 | margin: 0 auto;
6 | background-color: white;
7 | display: flex;
8 | justify-content: center;
9 | padding: 10px;
10 | & span {
11 | z-index: 1;
12 | margin: 0 5px;
13 | padding-right: 10px;
14 | }
15 | `;
16 |
17 | export default Container;
18 |
--------------------------------------------------------------------------------
/fe/src/components/molecules/IconButton/iconButton.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Home } from '@atoms';
3 | import IconButton from './index';
4 |
5 | export default {
6 | title: 'Molecules/IconButton',
7 | component: IconButton,
8 | };
9 |
10 | export const Default = () => {
11 | const label = 'home';
12 | return ;
13 | };
14 |
--------------------------------------------------------------------------------
/fe/src/components/molecules/IconButton/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent, ReactChild } from 'react';
2 | import { IconButton as MaterialIconButton, SvgIcon } from '@material-ui/core';
3 |
4 | interface Props {
5 | className?: string;
6 | onClick?: () => void;
7 | label?: string;
8 | icon: FunctionComponent;
9 | }
10 |
11 | const IconButton: FunctionComponent = ({ className, onClick, label = '', icon }) => (
12 |
13 |
14 |
15 | );
16 |
17 | export default IconButton;
18 |
--------------------------------------------------------------------------------
/fe/src/components/molecules/IconLabel/iconlabel.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Search, Text } from '@atoms';
3 | import IconLabel from './index';
4 |
5 | export default {
6 | title: 'Molecules/IconLabel',
7 | component: IconLabel,
8 | };
9 |
10 | export const Default = () => {
11 | const value = '관심사를 팔로우하세요.';
12 | const color = 'black';
13 | const size = '20px';
14 | const weight = 700;
15 |
16 | return (
17 |
18 |
19 |
20 |
21 |
22 | );
23 | };
24 |
--------------------------------------------------------------------------------
/fe/src/components/molecules/IconLabel/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent } from 'react';
2 | import StyledIconLabel from './styled';
3 |
4 | interface Props {
5 | children: React.ReactChild[];
6 | className?: string;
7 | }
8 |
9 | const IconLabel: FunctionComponent = ({ children, className }) => (
10 | {children}
11 | );
12 |
13 | export default IconLabel;
14 |
--------------------------------------------------------------------------------
/fe/src/components/molecules/IconLabel/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const StyledIconLabel = styled.div`
4 | display: flex;
5 | align-items: center;
6 | `;
7 |
8 | export default StyledIconLabel;
9 |
--------------------------------------------------------------------------------
/fe/src/components/molecules/InputContainer/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent } from 'react';
2 | import { Label, Input } from '@atoms';
3 | import Container from './styled';
4 |
5 | interface Props {
6 | labelValue: string;
7 | placeholder?: string;
8 | type?: string;
9 | inputValue: string;
10 | onChange: (e: React.SyntheticEvent) => void;
11 | className?: string;
12 | }
13 |
14 | const InputContainer: FunctionComponent = ({
15 | labelValue,
16 | placeholder = '',
17 | type,
18 | inputValue,
19 | onChange,
20 | className,
21 | }) => (
22 |
23 |
24 |
25 |
26 | );
27 |
28 | export default InputContainer;
29 |
--------------------------------------------------------------------------------
/fe/src/components/molecules/InputContainer/inputContainer.stories.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { useOnTextChange } from '@hooks';
3 | import Inputcontainer from './index';
4 |
5 | export default {
6 | title: 'Molecules/Inputcontainer',
7 | component: Inputcontainer,
8 | };
9 |
10 | export const Default = () => {
11 | const labelValue = '이메일';
12 | const placeholder = '입력!';
13 | const type = 'email';
14 | const [value, , onChange] = useOnTextChange('');
15 |
16 | return (
17 |
24 | );
25 | };
26 |
--------------------------------------------------------------------------------
/fe/src/components/molecules/InputContainer/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { Box } from '@material-ui/core';
3 |
4 | const Container = styled(Box)`
5 | display: inline-block;
6 | border-radius: 5px;
7 | padding: 0.5rem 1rem 0;
8 | margin-right: 1rem;
9 | background-color: #efefef;
10 | & label {
11 | width: 100%;
12 | }
13 | `;
14 |
15 | export default Container;
16 |
--------------------------------------------------------------------------------
/fe/src/components/molecules/Loading/Point.ts:
--------------------------------------------------------------------------------
1 | export default class Point {
2 | x: number;
3 |
4 | y: number;
5 |
6 | fixedY: number;
7 |
8 | speed: number = 0.04;
9 |
10 | cur: number;
11 |
12 | max: number;
13 |
14 | constructor(index: number, x: number, y: number) {
15 | this.x = x;
16 | this.y = y;
17 | this.fixedY = y - 100;
18 | this.cur = index;
19 | this.max = Math.random() * 100 + 60;
20 | }
21 |
22 | update() {
23 | this.cur += this.speed;
24 | this.y = this.fixedY + Math.sin(this.cur) * this.max;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/fe/src/components/molecules/Loading/Wave.ts:
--------------------------------------------------------------------------------
1 | import Point from './Point';
2 |
3 | export default class Wave {
4 | centerX: number;
5 |
6 | centerY: number;
7 |
8 | totalPoints: number;
9 |
10 | points: Point[] = [];
11 |
12 | index: number;
13 |
14 | color: string;
15 |
16 | pointGap: number;
17 |
18 | width: number;
19 |
20 | height: number;
21 |
22 | constructor(index: number, totalPoints: number, color: string, width: number, height: number) {
23 | this.index = index;
24 | this.totalPoints = totalPoints;
25 | this.color = color;
26 | this.width = width;
27 | this.height = height;
28 |
29 | this.centerX = 0;
30 | this.centerY = 0;
31 | this.pointGap = 0;
32 |
33 | this.init();
34 | }
35 |
36 | init() {
37 | this.centerX = this.width / 2;
38 | this.centerY = this.height / 2;
39 |
40 | for (let i = 0; i < this.totalPoints; i += 1) {
41 | this.pointGap = this.width / (this.totalPoints - 1);
42 |
43 | const point = new Point(this.index + i, this.pointGap * i, this.centerY);
44 | this.points[i] = point;
45 | }
46 | }
47 |
48 | draw(ctx: CanvasRenderingContext2D) {
49 | ctx.beginPath();
50 | ctx.fillStyle = this.color;
51 |
52 | let prevX = this.points[0].x;
53 | let prevY = this.points[0].y;
54 |
55 | ctx.moveTo(prevX, prevY);
56 |
57 | for (let i = 0; i < this.totalPoints; i += 1) {
58 | this.points[i].update();
59 |
60 | const cx = (prevX + this.points[i].x) / 2;
61 | const cy = (prevY + this.points[i].y) / 2;
62 |
63 | ctx.quadraticCurveTo(prevX, prevY, cx, cy);
64 |
65 | prevX = this.points[i].x;
66 | prevY = this.points[i].y;
67 | }
68 |
69 | ctx.lineTo(prevX, prevY);
70 | ctx.lineTo(this.width, this.height); // 우하단 꼭지점
71 | ctx.lineTo(this.points[0].x, this.height); // 좌하단 꼭지점
72 | ctx.fill();
73 | ctx.closePath();
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/fe/src/components/molecules/Loading/WaveGroup.ts:
--------------------------------------------------------------------------------
1 | import Wave from './Wave';
2 |
3 | const opacity = '0.7';
4 | const DARK_BLUE = `rgba(51, 93, 159, ${opacity})`;
5 | const BLUE = `rgba(126, 163, 213, ${opacity})`;
6 | const LIGHT_BLUE = `rgba(179, 210, 247, ${opacity})`;
7 |
8 | export default class WaveGroup {
9 | totalWaves: number = 3;
10 |
11 | totalPoints: number = 6;
12 |
13 | color: string[] = [DARK_BLUE, BLUE, LIGHT_BLUE];
14 |
15 | waves: Wave[] = [];
16 |
17 | constructor(width: number, height: number) {
18 | for (let i = 0; i < this.totalWaves; i += 1) {
19 | const wave = new Wave(i, this.totalPoints, this.color[i], width, height);
20 | this.waves[i] = wave;
21 | }
22 | }
23 |
24 | draw(ctx: CanvasRenderingContext2D) {
25 | for (let i = 0; i < this.totalWaves; i += 1) {
26 | const wave = this.waves[i];
27 | wave.draw(ctx);
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/fe/src/components/molecules/Loading/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from 'react';
2 | import { StyledDiv, LoadingCanvas, StyledP, Bird } from './styled';
3 | import WaveGroup from './WaveGroup';
4 |
5 | interface Props {
6 | message: string;
7 | }
8 |
9 | const Loading: React.FC = ({ message }) => {
10 | const canvasRef = useRef(null);
11 |
12 | useEffect(() => {
13 | if (canvasRef.current) {
14 | const canvas: HTMLCanvasElement = canvasRef.current;
15 | const ctx: CanvasRenderingContext2D = canvas.getContext('2d') as CanvasRenderingContext2D;
16 | canvas.width = canvas.clientWidth;
17 | canvas.height = canvas.clientHeight;
18 |
19 | const waveGroup = new WaveGroup(canvas.width, canvas.height);
20 |
21 | const animate = () => {
22 | ctx.clearRect(0, 0, canvas.width, canvas.height);
23 | waveGroup.draw(ctx);
24 | requestAnimationFrame(animate);
25 | };
26 |
27 | animate();
28 | }
29 | }, [message]);
30 |
31 | return (
32 |
33 |
34 |
35 | {message}
36 |
37 | );
38 | };
39 |
40 | export default Loading;
41 |
--------------------------------------------------------------------------------
/fe/src/components/molecules/Loading/styled.ts:
--------------------------------------------------------------------------------
1 | import styled, { keyframes } from 'styled-components';
2 |
3 | const StyledDiv = styled.div`
4 | display: block;
5 | position: fixed;
6 | width: 100vw;
7 | height: 100vh;
8 | z-index: 11;
9 | opacity: 0.5;
10 | overflow: hidden;
11 | `;
12 |
13 | const slide = keyframes`
14 | from {
15 | transform: translate(0, 0);
16 | }
17 | to {
18 | transform: translate(0, -10rem);
19 | }
20 | `;
21 |
22 | const LoadingCanvas = styled.canvas`
23 | width: 100%;
24 | height: calc(100% + 50rem);
25 | animation: ${slide} 3s ease-in-out infinite alternate;
26 | `;
27 |
28 | const grow = keyframes`
29 | 0% {
30 | transform: scale(1);
31 | opacity: 1;
32 | }
33 | 50% {
34 | transform: scale(1.2) skew(10deg);
35 | opacity: 0.5;
36 | }
37 | 100% {
38 | transform: scale(1);
39 | opacity: 1;
40 | }
41 | `;
42 |
43 | const StyledP = styled.p`
44 | margin: 0;
45 | font-size: 1.5rem;
46 | position: absolute;
47 | top: 30%;
48 | left: calc(50% - 15rem);
49 | animation: ${grow} 4s infinite linear;
50 | width: 30rem;
51 | text-align: center;
52 | z-index: 100;
53 | `;
54 |
55 | const fly = keyframes`
56 | 0% {
57 | left: 0;
58 | top: 20%;
59 | }
60 | 25% {
61 | left: 25%;
62 | top: 10%;
63 | }
64 | 50% {
65 | left: 50%;
66 | top: 20%;
67 | }
68 | 75% {
69 | left: 75%;
70 | top: 10%;
71 | }
72 | 100% {
73 | left: 100%;
74 | top: 20%;
75 | }
76 | `;
77 |
78 | const Bird = styled.img`
79 | position: absolute;
80 | width: 20rem;
81 | height: 20rem;
82 | top: 20%;
83 | left: 0;
84 | z-index: 20;
85 | animation: ${fly} 5s infinite linear;
86 | `;
87 |
88 | export { StyledDiv, LoadingCanvas, StyledP, Bird };
89 |
--------------------------------------------------------------------------------
/fe/src/components/molecules/LoadingCircle/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent } from 'react';
2 | import { CircularProgress } from '@material-ui/core';
3 | import Contaniner from './styled';
4 |
5 | interface Props {
6 | loadFinished: boolean;
7 | fetchMoreEl: React.RefObject;
8 | }
9 |
10 | const LoadingCircle: FunctionComponent = ({ loadFinished, fetchMoreEl }) => {
11 | if (loadFinished) return ;
12 |
13 | return (
14 |
15 |
16 |
17 | );
18 | };
19 |
20 | export default LoadingCircle;
21 |
--------------------------------------------------------------------------------
/fe/src/components/molecules/LoadingCircle/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const Container = styled.div`
4 | display: flex;
5 | justify-content: center;
6 | height: 50px;
7 | margin: 10px;
8 | `;
9 |
10 | export default Container;
11 |
--------------------------------------------------------------------------------
/fe/src/components/molecules/LoginForm/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent } from 'react';
2 | import { Form } from '@atoms';
3 |
4 | interface Props {
5 | children: React.ReactChild[];
6 | }
7 |
8 | const LoginForm: FunctionComponent = ({ children }) => ;
9 |
10 | export default LoginForm;
11 |
--------------------------------------------------------------------------------
/fe/src/components/molecules/LoginForm/loginForm.stories.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { text } from '@storybook/addon-knobs';
3 | import { InputContainer, Button } from '@molecules';
4 | import LoginForm from './index';
5 |
6 | export default {
7 | title: 'Molecules/LoginForm',
8 | component: LoginForm,
9 | };
10 |
11 | export const Default = () => {
12 | const emailLabelValue = '이메일';
13 | const emailPlaceholder = '입력!';
14 | const emailType = 'email';
15 |
16 | const passwordLabelValue = '비밀번호';
17 | const passwordPlaceholder = '입력!';
18 | const passwordType = 'password';
19 |
20 | const content = text('text', '로그인');
21 | const color = 'primary';
22 | const variant = 'contained';
23 | const borderRadius = 50;
24 |
25 | const onChange = () => {};
26 |
27 | return (
28 |
29 |
36 |
43 |
44 |
45 | );
46 | };
47 |
--------------------------------------------------------------------------------
/fe/src/components/molecules/Modal/Portal.tsx:
--------------------------------------------------------------------------------
1 | import ReactDOM from 'react-dom';
2 | import { ReactChild, FunctionComponent, useState, useEffect } from 'react';
3 |
4 | interface Props {
5 | children: ReactChild;
6 | }
7 |
8 | const Portal: FunctionComponent = ({ children }) => {
9 | const [el, setEl] = useState();
10 | useEffect(() => {
11 | setEl(document.getElementById('modal'));
12 | }, []);
13 | if (el) return ReactDOM.createPortal(children, el as Element);
14 | return null;
15 | };
16 |
17 | export default Portal;
18 |
--------------------------------------------------------------------------------
/fe/src/components/molecules/Modal/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent, ReactChild } from 'react';
2 | import { Dialog, DialogTitle, Slide } from '@material-ui/core';
3 | import { X } from '@atoms';
4 | import { IconButton } from '@molecules';
5 | import { TransitionProps } from '@material-ui/core/transitions/transition';
6 | import Portal from './Portal';
7 | import StyledDialogContent from './styled';
8 |
9 | interface Props {
10 | children: ReactChild | ReactChild[];
11 | displayModal: boolean;
12 | onClickCloseBtn: () => void;
13 | }
14 |
15 | const Transition = React.forwardRef(function Transition(
16 | props: TransitionProps & { children?: React.ReactElement },
17 | ref: React.Ref,
18 | ) {
19 | return ;
20 | });
21 |
22 | const Modal: FunctionComponent = ({ children, displayModal, onClickCloseBtn }) => {
23 | return (
24 |
25 |
37 |
38 | );
39 | };
40 |
41 | export default Modal;
42 |
--------------------------------------------------------------------------------
/fe/src/components/molecules/Modal/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { DialogContent } from '@material-ui/core';
3 |
4 | const StyledDialogContent = styled(DialogContent)`
5 | & div {
6 | border: none;
7 | }
8 | `;
9 |
10 | export default StyledDialogContent;
11 |
--------------------------------------------------------------------------------
/fe/src/components/molecules/SearchBar/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent } from 'react';
2 | import { Box } from '@material-ui/core';
3 | import { Search, Input } from '@atoms';
4 | import { SearchBox, SearchIconBox } from './styled';
5 |
6 | interface Props {
7 | placeholder?: string;
8 | value?: string;
9 | type: string;
10 | width?: string;
11 | onChange?: (e: React.SyntheticEvent) => void;
12 | onKeyDown?: (e: React.KeyboardEvent) => void;
13 | }
14 |
15 | const SearchBar: FunctionComponent = ({
16 | placeholder = '',
17 | value = '',
18 | type,
19 | width = '',
20 | onChange = (e) => {},
21 | onKeyDown = (e) => {},
22 | }) => (
23 |
24 |
25 |
26 |
27 |
28 |
35 |
36 |
37 | );
38 |
39 | export default SearchBar;
40 |
--------------------------------------------------------------------------------
/fe/src/components/molecules/SearchBar/searchBar.stories.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import SearchBar from './index';
3 |
4 | export default {
5 | title: 'Molecules/SearchBar',
6 | component: SearchBar,
7 | };
8 |
9 | export const Default = () => {
10 | const placeholder = 'Search Twitter';
11 | const type = 'text';
12 | return ;
13 | };
14 |
--------------------------------------------------------------------------------
/fe/src/components/molecules/TitleSubText/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent } from 'react';
2 | import { Box } from '@material-ui/core';
3 | import { Text } from '@atoms';
4 |
5 | interface Props {
6 | title: string;
7 | sub: string;
8 | className?: string;
9 | onClick?: () => void;
10 | }
11 |
12 | const TitleSubText: FunctionComponent = ({ title, sub, className, onClick }) => (
13 |
14 |
15 |
16 |
17 | );
18 |
19 | export default TitleSubText;
20 |
--------------------------------------------------------------------------------
/fe/src/components/molecules/TweetFooter/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent } from 'react';
2 | import { IconButton, Button } from '@molecules';
3 | import ButtonsBox from './styled';
4 |
5 | interface Props {
6 | icons: FunctionComponent<{}>[];
7 | onClick: () => void;
8 | iconOnClick: () => void;
9 | btnDisabled: boolean;
10 | }
11 |
12 | const TweetFooter: FunctionComponent = ({ icons, iconOnClick, onClick, btnDisabled }) => (
13 |
14 | {icons.map((icon, index) => (
15 | // eslint-disable-next-line react/no-array-index-key
16 |
17 | ))}
18 |
26 |
27 | );
28 | export default TweetFooter;
29 |
--------------------------------------------------------------------------------
/fe/src/components/molecules/TweetFooter/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { Box } from '@material-ui/core';
3 |
4 | const ButtonsBox = styled(Box)`
5 | display: flex;
6 | justify-content: space-between;
7 | `;
8 |
9 | export default ButtonsBox;
10 |
--------------------------------------------------------------------------------
/fe/src/components/molecules/UploadImg/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent } from 'react';
2 | import { X } from '@atoms';
3 | import { IconButton } from '@molecules';
4 |
5 | interface Props {
6 | onClick?: () => void;
7 | img?: string;
8 | }
9 |
10 | const UploadImg: FunctionComponent = ({ onClick = undefined, img }) => (
11 | <>
12 | {onClick ? : ''}
13 |
14 | >
15 | );
16 |
17 | export default UploadImg;
18 |
--------------------------------------------------------------------------------
/fe/src/components/molecules/UserInfo/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent } from 'react';
2 | import { ProfileImg } from '@atoms';
3 | import { Container, StyledTitleSub } from './styled';
4 |
5 | interface Props {
6 | onClick?: () => void;
7 | img?: string;
8 | title: string;
9 | sub: string;
10 | width?: string;
11 | }
12 |
13 | const UserInfo: FunctionComponent = ({
14 | onClick = () => {},
15 | img,
16 | title,
17 | sub,
18 | width = '',
19 | }) => (
20 |
21 |
22 |
23 |
24 | );
25 |
26 | export default UserInfo;
27 |
--------------------------------------------------------------------------------
/fe/src/components/molecules/UserInfo/styled.ts:
--------------------------------------------------------------------------------
1 | import { Box } from '@material-ui/core';
2 | import styled from 'styled-components';
3 | import { TitleSubText } from '@molecules';
4 |
5 | const Container = styled(Box)`
6 | border-radius: 15px;
7 | display: flex;
8 | align-items: center;
9 | cursor: pointer;
10 |
11 | &:hover {
12 | background-color: rgba(0, 0, 0, 0.04);
13 | }
14 | `;
15 |
16 | const StyledTitleSub = styled(TitleSubText)`
17 | margin-left: 5px;
18 |
19 | & span {
20 | display: block;
21 | }
22 | `;
23 |
24 | export { Container, StyledTitleSub };
25 |
--------------------------------------------------------------------------------
/fe/src/components/molecules/UserPopover/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent, ReactElement } from 'react';
2 | import { ListItem } from '@material-ui/core';
3 | import { Button } from '@molecules';
4 | import { useRouter } from 'next/router';
5 | import { useMyInfo } from '@hooks';
6 | import { recreateApollo } from '@libs';
7 | import { Container, StyledList } from './styled';
8 |
9 | interface ButtonProps {
10 | id: number;
11 | text: string;
12 | icon?: ReactElement | null;
13 | color?: 'primary' | 'inherit' | 'default' | 'secondary' | undefined;
14 | variant?: 'contained' | 'text' | 'outlined' | undefined;
15 | width?: string;
16 | onClick?: () => void;
17 | }
18 |
19 | const ITEM: Array = [
20 | { id: 0, text: 'USER INFO' },
21 | { id: 1, text: 'log out' },
22 | ];
23 |
24 | interface Props {
25 | closeEvent: () => void;
26 | }
27 |
28 | const UsePopover: FunctionComponent = ({ closeEvent }) => {
29 | const router = useRouter();
30 | const { myProfile } = useMyInfo();
31 | ITEM[0].onClick = () => {
32 | router.push('/[userId]/[[...type]]', `/${myProfile.user_id}/`, { shallow: true });
33 | closeEvent();
34 | };
35 | ITEM[1].onClick = () => {
36 | router.push('/login');
37 | document.cookie = 'jwt = ';
38 | recreateApollo();
39 | };
40 | return (
41 |
42 |
43 | {ITEM.map((v) => (
44 |
45 |
46 |
47 | ))}
48 |
49 |
50 | );
51 | };
52 |
53 | export default UsePopover;
54 |
--------------------------------------------------------------------------------
/fe/src/components/molecules/index.ts:
--------------------------------------------------------------------------------
1 | import Button from './Button';
2 | import Footer from './Footer';
3 | import IconButton from './IconButton';
4 | import IconLabel from './IconLabel';
5 | import InputContainer from './InputContainer';
6 | import Loading from './Loading';
7 | import LoadingCircle from './LoadingCircle';
8 | import LoginForm from './LoginForm';
9 | import Modal from './Modal';
10 | import SearchBar from './SearchBar';
11 | import TabBar from './TabBar';
12 | import TitleSubText from './TitleSubText';
13 | import TweetFooter from './TweetFooter';
14 | import UploadImg from './UploadImg';
15 | import UserInfo from './UserInfo';
16 | import UserPopover from './UserPopover';
17 | import ComponentLoading from './ComponentLoading';
18 |
19 | export {
20 | Button,
21 | Footer,
22 | IconButton,
23 | IconLabel,
24 | InputContainer,
25 | Loading,
26 | LoadingCircle,
27 | LoginForm,
28 | Modal,
29 | SearchBar,
30 | TabBar,
31 | TitleSubText,
32 | TweetFooter,
33 | UploadImg,
34 | UserInfo,
35 | UserPopover,
36 | ComponentLoading,
37 | };
38 |
--------------------------------------------------------------------------------
/fe/src/components/organisms/LoginLeftSection/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent } from 'react';
2 | import { Search, Home, Twitter, Text } from '@atoms';
3 | import { LogoContainer, Container, IconLabelContainer, StyledIconLabel } from './styled';
4 |
5 | const LoginLeftSection: FunctionComponent = () => {
6 | const IconTextcolor = 'white';
7 | const IconTextsize = '20px';
8 | const IconTextweight = 700;
9 |
10 | return (
11 |
12 |
13 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
29 |
30 |
31 |
32 |
33 |
39 |
40 |
41 |
42 |
43 |
49 |
50 |
51 |
52 | );
53 | };
54 |
55 | export default LoginLeftSection;
56 |
--------------------------------------------------------------------------------
/fe/src/components/organisms/LoginLeftSection/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { Box, SvgIcon } from '@material-ui/core';
3 | import { IconLabel } from '@molecules';
4 |
5 | const Container = styled(Box)`
6 | display: flex;
7 | flex-direction: column;
8 | justify-content: center;
9 | align-items: center;
10 | width: 50vw;
11 | height: 95vh;
12 | background-color: rgb(122, 204, 254);
13 | `;
14 |
15 | const LogoContainer = styled.div`
16 | position: fixed;
17 | overflow: hidden;
18 | color: rgb(29, 161, 242);
19 | `;
20 |
21 | const StyledIconLabel = styled(IconLabel)`
22 | && {
23 | margin-bottom: 40px;
24 |
25 | & svg {
26 | fill: #fff;
27 | }
28 | }
29 | `;
30 |
31 | const IconLabelContainer = styled(Box)`
32 | z-index: 1;
33 | `;
34 | export { StyledIconLabel, IconLabelContainer, Container, LogoContainer };
35 |
--------------------------------------------------------------------------------
/fe/src/components/organisms/LoginRightSection/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent } from 'react';
2 | import { useMutation } from '@apollo/client';
3 | import { LoginForm, InputContainer, Button } from '@molecules';
4 | import { Twitter } from '@atoms';
5 | import { useDisplay, useOnTextChange } from '@hooks';
6 | import { SignupModal } from '@organisms';
7 | import { LOCAL_LOGIN } from '@graphql/auth';
8 | import { useRouter } from 'next/router';
9 | import { recreateApollo } from '@libs';
10 | import { Container, JoinBox, LoginFormContainer, StyledButton, StyledText } from './styled';
11 |
12 | const LoginRightSection: FunctionComponent = () => {
13 | const [localLogin] = useMutation(LOCAL_LOGIN);
14 | const [displaySignupModal, , onClickSignupBtn] = useDisplay(false);
15 | const [userId, setUserId, onUserIdChange] = useOnTextChange('');
16 | const [password, setPassword, onPasswordChange] = useOnTextChange('');
17 | const router = useRouter();
18 |
19 | const onLoginBtnClick = async () => {
20 | try {
21 | await localLogin({ variables: { userId, password } });
22 | setUserId('');
23 | setPassword('');
24 | recreateApollo();
25 | router.push('/');
26 | } catch (err) {
27 | alert(err);
28 | router.push('/login');
29 | }
30 | };
31 |
32 | const onGithubBtnClick = () => {
33 | const GITHUB_LOGIN_URL =
34 | process.env.NODE_ENV === 'development'
35 | ? process.env.DEV_GITHUB_LOGIN_URL
36 | : process.env.PRO_GITHUB_LOGIN_URL;
37 | window.location.href = GITHUB_LOGIN_URL as string;
38 | };
39 | return (
40 | <>
41 |
42 |
43 |
44 |
51 |
58 |
65 |
66 |
67 |
68 |
69 |
74 |
81 |
89 |
90 |
91 |
92 | >
93 | );
94 | };
95 |
96 | export default LoginRightSection;
97 |
--------------------------------------------------------------------------------
/fe/src/components/organisms/LoginRightSection/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { Box } from '@material-ui/core';
3 | import { Button } from '@molecules';
4 | import { Text } from '@atoms';
5 |
6 | const LoginFormContainer = styled(Box)`
7 | position: absolute;
8 | top: 15px;
9 | `;
10 |
11 | const Container = styled(Box)`
12 | display: flex;
13 | flex-direction: column;
14 | justify-content: center;
15 | align-items: center;
16 | width: 50vw;
17 | height: 95vh;
18 | z-index: 1;
19 | background-color: white;
20 | `;
21 |
22 | const JoinBox = styled(Box)`
23 | display: flex;
24 | flex-direction: column;
25 | max-width: 400px;
26 | `;
27 |
28 | const StyledButton = styled(Button)`
29 | && {
30 | margin-top: 15px;
31 | }
32 | `;
33 |
34 | const StyledText = styled(Text)`
35 | && {
36 | margin-top: 15px;
37 | }
38 | `;
39 | export { Container, LoginFormContainer, JoinBox, StyledButton, StyledText };
40 |
--------------------------------------------------------------------------------
/fe/src/components/organisms/MainContainer/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent } from 'react';
2 | import Link from 'next/link';
3 | import { ProfileImg } from '@atoms';
4 | import { BodyContainer, Profile, MainBox } from './styled';
5 |
6 | interface Props {
7 | userId?: string;
8 | ProfileImgUrl?: string;
9 | children: React.ReactChild[];
10 | }
11 |
12 | const MainContaier: FunctionComponent = ({ userId = '', ProfileImgUrl = '', children }) => {
13 | return (
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | {children}
23 |
24 | );
25 | };
26 |
27 | export default MainContaier;
28 |
--------------------------------------------------------------------------------
/fe/src/components/organisms/MainContainer/styled.ts:
--------------------------------------------------------------------------------
1 | import { Box } from '@material-ui/core';
2 | import styled from 'styled-components';
3 |
4 | const MainBox = styled(Box)`
5 | padding: 5px;
6 | border-bottom: 1px solid rgb(235, 238, 240);
7 | display: flex;
8 | `;
9 |
10 | const BodyContainer = styled(Box)`
11 | margin: 0 auto;
12 | width: 85%;
13 | `;
14 |
15 | const Profile = styled(Box)`
16 | margin: 0 auto;
17 | `;
18 |
19 | export { MainBox, Profile, BodyContainer };
20 |
--------------------------------------------------------------------------------
/fe/src/components/organisms/NewTweetContainer/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const UploadImage = styled.input`
4 | display: none;
5 | `;
6 |
7 | export default UploadImage;
8 |
--------------------------------------------------------------------------------
/fe/src/components/organisms/NotificationContainer/FollowContainer/FollowContainer.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import FollowContainer from './index';
3 |
4 | export default {
5 | title: 'Organisms/FollowContainer',
6 | component: FollowContainer,
7 | };
8 |
9 | export const Default = () => {
10 | const user = {
11 | user_id: '홍길동',
12 | name: '길동',
13 | profile_img_url: 'https://avatars2.githubusercontent.com/u/46195613?v=4',
14 | comment: '',
15 | };
16 |
17 | return ;
18 | };
19 |
--------------------------------------------------------------------------------
/fe/src/components/organisms/NotificationContainer/FollowContainer/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent } from 'react';
2 | import { UserInfo } from '@molecules';
3 | import { BodyContainer, Container } from './styled';
4 |
5 | interface Props {
6 | user: {
7 | user_id: string;
8 | name: string;
9 | profile_img_url?: string;
10 | comment?: string;
11 | };
12 | }
13 |
14 | const FollowContainer: FunctionComponent = ({ user }) => {
15 | return (
16 |
17 |
18 | followed you
19 |
20 | );
21 | };
22 |
23 | export default FollowContainer;
24 |
--------------------------------------------------------------------------------
/fe/src/components/organisms/NotificationContainer/FollowContainer/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const BodyContainer = styled.div`
4 | margin: auto 10px;
5 | `;
6 | const Container = styled.div`
7 | display: flex;
8 | margin: 10px;
9 | `;
10 |
11 | export { BodyContainer, Container };
12 |
--------------------------------------------------------------------------------
/fe/src/components/organisms/NotificationContainer/HeartContainer/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent } from 'react';
2 | import { UserInfo } from '@molecules';
3 | import { FullHeart } from '@atoms';
4 | import { TweetType, UserType } from '@types';
5 | import { Container, SubContainer, Span, Content } from './styled';
6 |
7 | interface Props {
8 | user: UserType;
9 | tweet: TweetType;
10 | }
11 |
12 | const HeartContainer: FunctionComponent = ({ tweet, user }) => {
13 | return (
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | liked your Tweet!
22 |
23 | {tweet.content}
24 |
25 |
26 | );
27 | };
28 |
29 | export default HeartContainer;
30 |
--------------------------------------------------------------------------------
/fe/src/components/organisms/NotificationContainer/HeartContainer/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const Container = styled.div`
4 | display: flex;
5 | `;
6 |
7 | const SubContainer = styled.div`
8 | margin: 5px;
9 | padding: 5px;
10 | `;
11 | const Span = styled.span`
12 | margin: auto 5px;
13 | `;
14 |
15 | const Content = styled.div`
16 | margin: 10px 0;
17 | font-size: 20px;
18 | `;
19 |
20 | export { Container, SubContainer, Span, Content };
21 |
--------------------------------------------------------------------------------
/fe/src/components/organisms/NotificationContainer/RetweetContainer/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent } from 'react';
2 | import { UserInfo } from '@molecules';
3 | import { Retweet } from '@atoms';
4 | import { TweetType, UserType } from '@types';
5 | import { Container, SubContainer, Span, Content } from './styled';
6 |
7 | interface Props {
8 | user: UserType;
9 | tweet: TweetType;
10 | }
11 |
12 | const HeartContainer: FunctionComponent = ({ tweet, user }) => {
13 | return (
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | retweet your Tweet!
22 |
23 | {tweet.content}
24 |
25 |
26 | );
27 | };
28 |
29 | export default HeartContainer;
30 |
--------------------------------------------------------------------------------
/fe/src/components/organisms/NotificationContainer/RetweetContainer/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const Container = styled.div`
4 | display: flex;
5 | `;
6 |
7 | const SubContainer = styled.div`
8 | margin: 5px;
9 | padding: 5px;
10 | & svg {
11 | fill: rgb(23, 191, 99);
12 | }
13 | `;
14 | const Span = styled.span`
15 | margin: auto 5px;
16 | `;
17 |
18 | const Content = styled.div`
19 | margin: 10px 0;
20 | font-size: 20px;
21 | `;
22 |
23 | export { Container, SubContainer, Span, Content };
24 |
--------------------------------------------------------------------------------
/fe/src/components/organisms/NotificationContainer/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent } from 'react';
2 | import Link from 'next/link';
3 | import { DocumentNode } from 'graphql';
4 | import { useMyInfo } from '@hooks';
5 | import { TweetContainer } from '@organisms';
6 | import { NotificationType } from '@types';
7 | import HeartContainer from './HeartContainer';
8 | import { Container, UnderLine } from './styled';
9 | import FollowContainer from './FollowContainer';
10 | import RetweetContainer from './RetweetContainer';
11 |
12 | interface Props {
13 | noti: NotificationType;
14 | curTabValue: String;
15 | updateQuery: { query: DocumentNode; variables?: {} };
16 | }
17 |
18 | const NotificationContainer: FunctionComponent = ({
19 | noti: { giver, tweet, type, _id },
20 | curTabValue,
21 | updateQuery,
22 | }) => {
23 | const { myProfile } = useMyInfo();
24 | const isRead = myProfile.lastest_notification_id < _id;
25 |
26 | if (type === 'mention')
27 | return (
28 |
29 |
30 |
31 | );
32 |
33 | if (curTabValue === 'all') {
34 | if (type === 'follow')
35 | return (
36 |
37 |
38 |
39 |
40 |
41 |
42 | );
43 | if (type === 'heart')
44 | return (
45 |
46 |
47 |
48 |
49 | );
50 | return (
51 |
52 |
53 |
54 |
55 | );
56 | }
57 | return <>>;
58 | };
59 |
60 | export default NotificationContainer;
61 |
--------------------------------------------------------------------------------
/fe/src/components/organisms/NotificationContainer/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const Container = styled.div`
4 | background: ${(props) => props.color};
5 | `;
6 |
7 | const BodyContainer = styled.div`
8 | margin: auto 0;
9 | `;
10 |
11 | const UnderLine = styled.div`
12 | width: 100%;
13 | display: flex;
14 | padding: 5px;
15 | border-bottom: 1px solid rgb(235, 238, 240);
16 | `;
17 |
18 | export { BodyContainer, Container, UnderLine };
19 |
--------------------------------------------------------------------------------
/fe/src/components/organisms/PageLayout/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent, ReactChild } from 'react';
2 | import { SideBar } from '@organisms';
3 | import { DocumentNode } from 'graphql';
4 | import { Container, MainContainer } from './styled';
5 |
6 | interface Props {
7 | children: ReactChild[];
8 | page?: string;
9 | updateQuery?: { query: DocumentNode; variables?: {} };
10 | }
11 |
12 | const PageLayout: FunctionComponent = ({ children, page, updateQuery }) => {
13 | return (
14 |
15 |
16 | {children}
17 |
18 | );
19 | };
20 |
21 | export default PageLayout;
22 |
--------------------------------------------------------------------------------
/fe/src/components/organisms/RetweetContainer/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent } from 'react';
2 | import Link from 'next/link';
3 | import Markdown from 'react-markdown/with-html';
4 | import { TitleSubText } from '@molecules';
5 | import { ProfileImg } from '@atoms';
6 | import { TweetType } from '@types';
7 | import { RetweetBox, BodyContainer, HeaderContainer } from './styled';
8 |
9 | interface Props {
10 | tweet: TweetType;
11 | }
12 |
13 | const RetweetContainer: FunctionComponent = ({ tweet }) => (
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | {tweet.content}
23 |
24 |
25 |
26 |
27 | );
28 |
29 | export default RetweetContainer;
30 |
--------------------------------------------------------------------------------
/fe/src/components/organisms/RetweetContainer/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { Box } from '@material-ui/core';
3 | import { Button } from '@molecules';
4 |
5 | const ButtonsBox = styled(Box)`
6 | display: flex;
7 | justify-content: space-between;
8 | `;
9 |
10 | const RetweetBox = styled.div`
11 | padding: 5px;
12 | border: 1px solid rgb(235, 238, 240);
13 | border-radius: 10px;
14 | `;
15 |
16 | const BodyContainer = styled(Box)`
17 | margin: 0 auto;
18 | width: 85%;
19 | `;
20 |
21 | const HeaderContainer = styled(Box)`
22 | padding: 5px;
23 | display: flex;
24 | `;
25 |
26 | const PinkButton = styled(Button)`
27 | && {
28 | & svg {
29 | fill: #f783ac;
30 | }
31 | }
32 | `;
33 |
34 | export { ButtonsBox, RetweetBox, BodyContainer, HeaderContainer, PinkButton };
35 |
--------------------------------------------------------------------------------
/fe/src/components/organisms/SignupModal/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent, useState, useEffect } from 'react';
2 | import { useMutation } from '@apollo/client';
3 | import { Modal } from '@molecules';
4 | import { useOnTextChange } from '@hooks';
5 | import { ADD_USER } from '@graphql/user';
6 | import { StyledInputContainer, StyledButton } from './styled';
7 |
8 | interface Props {
9 | displayModal: boolean;
10 | onClickCloseBtn: () => void;
11 | }
12 |
13 | const SignupModal: FunctionComponent = ({ displayModal, onClickCloseBtn }) => {
14 | const [createUser] = useMutation(ADD_USER);
15 | const [userId, setUserId, onUserIdChange] = useOnTextChange('');
16 | const [name, setName, onNameChange] = useOnTextChange('');
17 | const [password, setPassword, onPasswordChange] = useOnTextChange('');
18 | const [btnDisabled, setBtnDisabled] = useState(true);
19 |
20 | useEffect(() => {
21 | setBtnDisabled(!userId || !name || !password);
22 | }, [userId, name, password]);
23 |
24 | const onSignupBtnClick = () => {
25 | createUser({ variables: { userId, name, password } });
26 | setUserId('');
27 | setName('');
28 | setPassword('');
29 | onClickCloseBtn();
30 | };
31 | const onCloseBtnClick = () => {
32 | setUserId('');
33 | setName('');
34 | setPassword('');
35 | onClickCloseBtn();
36 | };
37 | return (
38 |
39 |
40 |
41 |
47 |
54 |
55 | );
56 | };
57 |
58 | export default SignupModal;
59 |
--------------------------------------------------------------------------------
/fe/src/components/organisms/SignupModal/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { InputContainer, Button } from '@molecules';
3 |
4 | const StyledInputContainer = styled(InputContainer)`
5 | && {
6 | width: 100%;
7 | box-sizing: border-box;
8 | margin-bottom: 1vh;
9 | margin-right: 0;
10 | }
11 | `;
12 |
13 | const StyledButton = styled(Button)`
14 | && {
15 | width: 100%;
16 | box-sizing: border-box;
17 | margin-top: 1vh;
18 | margin-bottom: 1vh;
19 | }
20 | `;
21 |
22 | export { StyledInputContainer, StyledButton };
23 |
--------------------------------------------------------------------------------
/fe/src/components/organisms/TweetContainer/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { Box } from '@material-ui/core';
3 | import { Button } from '@molecules';
4 |
5 | const ButtonsBox = styled(Box)`
6 | display: flex;
7 | justify-content: space-between;
8 | `;
9 |
10 | const PinkButton = styled(Button)`
11 | && {
12 | & svg {
13 | fill: #f783ac;
14 | }
15 | }
16 | `;
17 |
18 | const TweetHeaderContainer = styled(Box)`
19 | display: flex;
20 | justify-content: space-between;
21 | `;
22 |
23 | const HeaderInfoContainer = styled(Box)`
24 | display: flex;
25 |
26 | & span {
27 | margin-right: 5px;
28 | }
29 | `;
30 |
31 | export { ButtonsBox, PinkButton, TweetHeaderContainer, HeaderInfoContainer };
32 |
--------------------------------------------------------------------------------
/fe/src/components/organisms/TweetDetailContainer/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { Box } from '@material-ui/core';
3 | import { IconButton } from '@molecules';
4 |
5 | const DetailContainer = styled(Box)`
6 | display: flex;
7 | flex-direction: column;
8 | border-bottom: solid 1px rgb(235, 238, 240);
9 | padding: 15px;
10 | `;
11 |
12 | const TweetDetailInfoContainer = styled.div`
13 | display: flex;
14 | & div:first-child {
15 | margin-right: 10px;
16 | }
17 | `;
18 |
19 | const ButtonsContainer = styled(Box)`
20 | display: flex;
21 | align-items: center;
22 | justify-content: space-around;
23 | `;
24 |
25 | const PinkIconButton = styled(IconButton)`
26 | && {
27 | & svg {
28 | fill: #f783ac;
29 | }
30 | }
31 | `;
32 |
33 | const TweetHeaderContainer = styled(Box)`
34 | display: flex;
35 | justify-content: space-between;
36 | `;
37 |
38 | const TimeContainer = styled(Box)`
39 | margin-top: 5px;
40 | `;
41 |
42 | export {
43 | DetailContainer,
44 | TweetDetailInfoContainer,
45 | ButtonsContainer,
46 | PinkIconButton,
47 | TweetHeaderContainer,
48 | TimeContainer,
49 | };
50 |
--------------------------------------------------------------------------------
/fe/src/components/organisms/TweetModal/HeartListModal.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent } from 'react';
2 | import { useQuery } from '@apollo/client';
3 | import { Modal, ComponentLoading } from '@molecules';
4 | import { UserCard } from '@organisms';
5 | import { GET_HEART_USERLIST } from '@graphql/user';
6 | import { UserType } from '@types';
7 |
8 | interface Props {
9 | displayModal: boolean;
10 | onClickCloseBtn: () => void;
11 | tweetId: string;
12 | }
13 |
14 | const HeartListModal: FunctionComponent = ({ displayModal, onClickCloseBtn, tweetId }) => {
15 | const { data } = useQuery(GET_HEART_USERLIST, { variables: { tweetId } });
16 |
17 | return (
18 |
19 | {data ? (
20 | data.userList?.map((user: UserType, index: number) => )
21 | ) : (
22 |
23 | )}
24 |
25 | );
26 | };
27 |
28 | export default HeartListModal;
29 |
--------------------------------------------------------------------------------
/fe/src/components/organisms/TweetModal/NewTweetModal.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent } from 'react';
2 | import { useMutation } from '@apollo/client';
3 | import { DocumentNode } from 'graphql';
4 | import { Modal } from '@molecules';
5 | import { NewTweetContainer } from '@organisms';
6 | import { ADD_BASIC_TWEET } from '@graphql/tweet';
7 |
8 | interface Props {
9 | displayModal: boolean;
10 | onClickCloseBtn: () => void;
11 | updateQuery?: { query: DocumentNode; variables?: {}; object?: boolean };
12 | }
13 |
14 | const NewTweetModal: FunctionComponent = ({
15 | displayModal,
16 | onClickCloseBtn,
17 | updateQuery,
18 | }) => {
19 | const [addBasicTweet, { loading: mutationLoading, error: mutationError }] = useMutation(
20 | ADD_BASIC_TWEET,
21 | );
22 | return (
23 |
24 |
29 |
30 | );
31 | };
32 |
33 | export default NewTweetModal;
34 |
--------------------------------------------------------------------------------
/fe/src/components/organisms/TweetModal/ReplyModal.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent } from 'react';
2 | import { DocumentNode } from 'graphql';
3 | import { useMutation } from '@apollo/client';
4 | import Markdown from 'react-markdown/with-html';
5 | import { Modal, TitleSubText } from '@molecules';
6 | import { NewTweetContainer, MainContainer } from '@organisms';
7 | import { ADD_REPLY_TWEET } from '@graphql/tweet';
8 | import { TweetType } from '@types';
9 |
10 | interface Props {
11 | displayModal: boolean;
12 | onClickCloseBtn: () => void;
13 | tweet: TweetType;
14 | updateQuery?: { query: DocumentNode; variables?: {} };
15 | }
16 |
17 | const TweetReplyModal: FunctionComponent = ({
18 | displayModal,
19 | onClickCloseBtn,
20 | tweet,
21 | updateQuery,
22 | }) => {
23 | const [addReplyTweet, { loading: mutationLoading, error: mutationError }] = useMutation(
24 | ADD_REPLY_TWEET,
25 | );
26 |
27 | return (
28 |
29 |
30 |
31 | {tweet.content}
32 |
33 |
39 |
40 | );
41 | };
42 |
43 | export default TweetReplyModal;
44 |
--------------------------------------------------------------------------------
/fe/src/components/organisms/TweetModal/RetweetListModal.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent } from 'react';
2 | import { useQuery } from '@apollo/client';
3 | import { Modal, Loading } from '@molecules';
4 | import { UserCard } from '@organisms';
5 | import { GET_RETWEET_USERLIST } from '@graphql/user';
6 | import { UserType } from '@types';
7 |
8 | interface Props {
9 | displayModal: boolean;
10 | onClickCloseBtn: () => void;
11 | tweetId: string;
12 | }
13 |
14 | const RetweetListModal: FunctionComponent = ({ displayModal, onClickCloseBtn, tweetId }) => {
15 | const { loading, error, data } = useQuery(GET_RETWEET_USERLIST, { variables: { tweetId } });
16 |
17 | return (
18 |
19 | {data ? (
20 | data.userList?.map((user: UserType, index: number) => )
21 | ) : (
22 |
23 | )}
24 |
25 | );
26 | };
27 |
28 | export default RetweetListModal;
29 |
--------------------------------------------------------------------------------
/fe/src/components/organisms/TweetModal/RetweetModal.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent } from 'react';
2 | import { useMutation } from '@apollo/client';
3 | import { DocumentNode } from 'graphql';
4 | import { Modal } from '@molecules';
5 | import { NewTweetContainer } from '@organisms';
6 | import { ADD_RETWEET } from '@graphql/tweet';
7 | import { TweetType } from '@types';
8 |
9 | interface Props {
10 | displayModal: boolean;
11 | onClickCloseBtn: () => void;
12 | tweet: TweetType;
13 | updateQuery: { query: DocumentNode; variables?: {}; object?: boolean };
14 | }
15 |
16 | const RetweetModal: FunctionComponent = ({
17 | displayModal,
18 | onClickCloseBtn,
19 | tweet,
20 | updateQuery,
21 | }) => {
22 | const [addRetweet, { loading: mutationLoading, error: mutationError }] = useMutation(ADD_RETWEET);
23 |
24 | return (
25 |
26 |
32 |
33 | );
34 | };
35 |
36 | export default RetweetModal;
37 |
--------------------------------------------------------------------------------
/fe/src/components/organisms/TweetModal/index.ts:
--------------------------------------------------------------------------------
1 | import HeartListModal from './HeartListModal';
2 | import NewTweetModal from './NewTweetModal';
3 | import ReplyModal from './ReplyModal';
4 | import RetweetModal from './RetweetModal';
5 | import RetweetListModal from './RetweetListModal';
6 |
7 | export { HeartListModal, NewTweetModal, ReplyModal, RetweetModal, RetweetListModal };
8 |
--------------------------------------------------------------------------------
/fe/src/components/organisms/UserCard/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent } from 'react';
2 | import Link from 'next/link';
3 | import { UserInfo, Button } from '@molecules';
4 | import { Text } from '@atoms';
5 | import { useUserState } from '@hooks';
6 | import { getJSXwithUserState } from '@libs';
7 | import { UserType } from '@types';
8 | import { Container, EmptyDiv } from './styled';
9 |
10 | interface Props {
11 | user: UserType;
12 | }
13 |
14 | const UserCard: FunctionComponent = ({ user }) => {
15 | const [userState, onClickFollow, onClickUnfollow] = useUserState(user);
16 |
17 | return (
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | {getJSXwithUserState(
26 | userState,
27 | ,
28 | ,
29 | ,
30 | )}
31 |
32 | );
33 | };
34 |
35 | export default UserCard;
36 |
--------------------------------------------------------------------------------
/fe/src/components/organisms/UserCard/styled.ts:
--------------------------------------------------------------------------------
1 | import { Box } from '@material-ui/core';
2 | import styled from 'styled-components';
3 |
4 | const Container = styled(Box)`
5 | padding: 0.5rem;
6 | display: flex;
7 | align-items: center;
8 | cursor: pointer;
9 | justify-content: space-between;
10 | border-bottom: 1px solid rgb(235, 238, 240);
11 | `;
12 |
13 | const EmptyDiv = styled.div`
14 | width: 120px;
15 | `;
16 |
17 | export { EmptyDiv, Container };
18 |
--------------------------------------------------------------------------------
/fe/src/components/organisms/UserDetailContainer/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent } from 'react';
2 | import Link from 'next/link';
3 | import { useQuery } from '@apollo/client';
4 | import { TitleSubText, Button } from '@molecules';
5 | import { ProfileImg, Text } from '@atoms';
6 | import { useDisplay, useUserState } from '@hooks';
7 | import { getJSXwithUserState } from '@libs';
8 | import { GET_USER_DETAIL } from '@graphql/user';
9 | import { QueryVariableType } from '@types';
10 | import {
11 | DetailContainer,
12 | UserBackgroundContainer,
13 | BottomContainer,
14 | UserMainContainer,
15 | TopContainer,
16 | UserFollowContainer,
17 | UserImgContainer,
18 | ImgCircleContainer,
19 | } from './styled';
20 | import UserEditModal from '../UserEditModal';
21 |
22 | interface Props {
23 | userId: string;
24 | }
25 |
26 | const UserDetailContainer: FunctionComponent = ({ children, userId }) => {
27 | const queryVariable: QueryVariableType = { variables: { userId: userId as string } };
28 | const { data } = useQuery(GET_USER_DETAIL, queryVariable);
29 | const [userState, onClickFollow, onClickUnfollow] = useUserState(data?.user, {
30 | query: GET_USER_DETAIL,
31 | variables: { userId: data.user.user_id },
32 | });
33 | const [displayModal, , onClickEditModal] = useDisplay(false);
34 |
35 | const PROFILE_IMG_SIZE = 150;
36 |
37 | const { user, followerCount } = data;
38 |
39 | return (
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 | {getJSXwithUserState(
52 | userState,
53 | ,
54 | ,
60 | ,
61 | )}
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 | {children}
80 |
81 |
82 |
83 | );
84 | };
85 |
86 | export default UserDetailContainer;
87 |
--------------------------------------------------------------------------------
/fe/src/components/organisms/UserEditModal/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent } from 'react';
2 | import { useMutation } from '@apollo/client';
3 | import { Modal } from '@molecules';
4 | import { useOnTextChange } from '@hooks';
5 | import { UPDATE_USER_INFO, GET_MYINFO } from '@graphql/user';
6 | import { UserType } from '@types';
7 | import { StyledInputContainer, StyledButton } from './styled';
8 |
9 | interface Props {
10 | displayModal: boolean;
11 | onClickCloseBtn: () => void;
12 | user: UserType;
13 | }
14 |
15 | const UserEditModal: FunctionComponent = ({ displayModal, onClickCloseBtn, user }) => {
16 | const [editUser, { loading: mutationLoading, error: mutationError }] = useMutation(
17 | UPDATE_USER_INFO,
18 | );
19 | const [name, setName, onNameChange] = useOnTextChange(user.name);
20 | const [comment, setComment, onCommentChange] = useOnTextChange(user.comment || '');
21 |
22 | const onEditBtnClick = () => {
23 | editUser({
24 | variables: { name, comment },
25 | update: (cache) => {
26 | cache.writeQuery({
27 | query: GET_MYINFO,
28 | data: { myProfile: { ...user, name, comment } },
29 | });
30 | },
31 | });
32 | onClickCloseBtn();
33 | };
34 |
35 | const onCloseBtnClick = () => {
36 | setName(user.name);
37 | setComment(user.comment || '');
38 | onClickCloseBtn();
39 | };
40 |
41 | return (
42 |
43 |
44 |
50 |
57 |
58 | );
59 | };
60 |
61 | export default UserEditModal;
62 |
--------------------------------------------------------------------------------
/fe/src/components/organisms/UserEditModal/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { InputContainer, Button } from '@molecules';
3 |
4 | const StyledInputContainer = styled(InputContainer)`
5 | && {
6 | width: 100%;
7 | box-sizing: border-box;
8 | margin-bottom: 1vh;
9 | margin-right: 0;
10 | }
11 | `;
12 |
13 | const StyledButton = styled(Button)`
14 | && {
15 | width: 100%;
16 | box-sizing: border-box;
17 | margin-top: 1vh;
18 | margin-bottom: 1vh;
19 | }
20 | `;
21 |
22 | export { StyledInputContainer, StyledButton };
23 |
--------------------------------------------------------------------------------
/fe/src/components/organisms/index.ts:
--------------------------------------------------------------------------------
1 | import LoginLeftSection from './LoginLeftSection';
2 | import LoginRightSection from './LoginRightSection';
3 | import MainContainer from './MainContainer';
4 | import NewTweetContainer from './NewTweetContainer';
5 | import NotificationContainer from './NotificationContainer';
6 | import PageLayout from './PageLayout';
7 | import RetweetContainer from './RetweetContainer';
8 | import SideBar from './SideBar';
9 | import SignupModal from './SignupModal';
10 | import TweetContainer from './TweetContainer';
11 | import TweetDetailContainer from './TweetDetailContainer';
12 | import {
13 | HeartListModal,
14 | NewTweetModal,
15 | ReplyModal,
16 | RetweetListModal,
17 | RetweetModal,
18 | } from './TweetModal';
19 | import UserCard from './UserCard';
20 | import UserDetailContainer from './UserDetailContainer';
21 | import UserEditModal from './UserEditModal';
22 |
23 | export {
24 | LoginLeftSection,
25 | LoginRightSection,
26 | MainContainer,
27 | NewTweetContainer,
28 | NotificationContainer,
29 | PageLayout,
30 | RetweetContainer,
31 | SideBar,
32 | SignupModal,
33 | TweetContainer,
34 | TweetDetailContainer,
35 | HeartListModal,
36 | NewTweetModal,
37 | ReplyModal,
38 | RetweetListModal,
39 | RetweetModal,
40 | UserEditModal,
41 | UserCard,
42 | UserDetailContainer,
43 | };
44 |
--------------------------------------------------------------------------------
/fe/src/graphql/auth/index.ts:
--------------------------------------------------------------------------------
1 | import { GITHUB_LOGIN, LOCAL_LOGIN } from './login';
2 |
3 | export { GITHUB_LOGIN, LOCAL_LOGIN };
4 |
--------------------------------------------------------------------------------
/fe/src/graphql/auth/login.ts:
--------------------------------------------------------------------------------
1 | import gql from 'graphql-tag';
2 |
3 | export const GITHUB_LOGIN = gql`
4 | mutation($code: String!) {
5 | auth: github_login(code: $code) {
6 | token
7 | }
8 | }
9 | `;
10 |
11 | export const LOCAL_LOGIN = gql`
12 | mutation($userId: String!, $password: String!) {
13 | auth: local_login(user_id: $userId, password: $password) {
14 | token
15 | }
16 | }
17 | `;
18 |
--------------------------------------------------------------------------------
/fe/src/graphql/custom/customQuery.ts:
--------------------------------------------------------------------------------
1 | import gql from 'graphql-tag';
2 |
3 | const DETAIL_PAGE = gql`
4 | query($userId: String, $oldestTweetId: String) {
5 | tweetList: user_tweet_list(user_id: $userId, oldest_tweet_id: $oldestTweetId) {
6 | _id
7 | createAt
8 | content
9 | child_tweet_number
10 | retweet_user_number
11 | heart_user_number
12 | retweet_id
13 | img_url_list
14 | retweet {
15 | _id
16 | content
17 | child_tweet_number
18 | retweet_user_number
19 | heart_user_number
20 | img_url_list
21 | author {
22 | user_id
23 | name
24 | profile_img_url
25 | }
26 | }
27 | author {
28 | _id
29 | user_id
30 | name
31 | profile_img_url
32 | }
33 | }
34 | user: user_info(user_id: $userId) {
35 | _id
36 | user_id
37 | name
38 | comment
39 | profile_img_url
40 | background_img_url
41 | following_id_list
42 | }
43 | followerCount: follower_count(user_id: $userId) {
44 | count
45 | }
46 | }
47 | `;
48 |
49 | export default DETAIL_PAGE;
50 |
--------------------------------------------------------------------------------
/fe/src/graphql/custom/index.ts:
--------------------------------------------------------------------------------
1 | import DETAIL_PAGE from './customQuery';
2 |
3 | export default DETAIL_PAGE;
4 |
--------------------------------------------------------------------------------
/fe/src/graphql/image/addImage.ts:
--------------------------------------------------------------------------------
1 | import gql from 'graphql-tag';
2 |
3 | const UPLOAD_IMAGE = gql`
4 | mutation($file: Upload!) {
5 | img: single_upload(file: $file) {
6 | img_url
7 | }
8 | }
9 | `;
10 |
11 | export default UPLOAD_IMAGE;
12 |
--------------------------------------------------------------------------------
/fe/src/graphql/image/index.ts:
--------------------------------------------------------------------------------
1 | import UPLOAD_IMAGE from './addImage';
2 |
3 | export default UPLOAD_IMAGE;
4 |
--------------------------------------------------------------------------------
/fe/src/graphql/notification/getNotification.ts:
--------------------------------------------------------------------------------
1 | import gql from 'graphql-tag';
2 |
3 | export const GET_NOTIFICATION_LIST = gql`
4 | query($id: String) {
5 | notifications: notification_list(oldest_notification_id: $id) {
6 | giver {
7 | _id
8 | user_id
9 | name
10 | comment
11 | profile_img_url
12 | }
13 | tweet {
14 | _id
15 | createAt
16 | content
17 | child_tweet_number
18 | retweet_user_number
19 | heart_user_number
20 | img_url_list
21 | retweet {
22 | _id
23 | content
24 | child_tweet_number
25 | retweet_user_number
26 | heart_user_number
27 | img_url_list
28 | author {
29 | user_id
30 | name
31 | profile_img_url
32 | }
33 | }
34 | author {
35 | user_id
36 | name
37 | profile_img_url
38 | }
39 | }
40 | type
41 | _id
42 | createAt
43 | }
44 | }
45 | `;
46 |
47 | export const GET_MENTION_NOTIFICATION_LIST = gql`
48 | query($id: String) {
49 | notifications: notification_mention_list(oldest_notification_id: $id) {
50 | giver {
51 | _id
52 | user_id
53 | name
54 | comment
55 | profile_img_url
56 | }
57 | tweet {
58 | _id
59 | createAt
60 | content
61 | child_tweet_number
62 | retweet_user_number
63 | heart_user_number
64 | img_url_list
65 | retweet {
66 | _id
67 | content
68 | child_tweet_number
69 | retweet_user_number
70 | heart_user_number
71 | img_url_list
72 | author {
73 | user_id
74 | name
75 | profile_img_url
76 | }
77 | }
78 | author {
79 | user_id
80 | name
81 | profile_img_url
82 | }
83 | }
84 | type
85 | _id
86 | createAt
87 | }
88 | }
89 | `;
90 |
91 | export const GET_NOTIFICATION_COUNT = gql`
92 | query($id: String) {
93 | count: notification_count(lastest_notification_id: $id) {
94 | count
95 | }
96 | }
97 | `;
98 |
--------------------------------------------------------------------------------
/fe/src/graphql/notification/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | GET_NOTIFICATION_LIST,
3 | GET_MENTION_NOTIFICATION_LIST,
4 | GET_NOTIFICATION_COUNT,
5 | } from './getNotification';
6 | import CONFIRM_NOTIFICATION from './modifyNotification';
7 |
8 | export {
9 | GET_NOTIFICATION_LIST,
10 | GET_MENTION_NOTIFICATION_LIST,
11 | GET_NOTIFICATION_COUNT,
12 | CONFIRM_NOTIFICATION,
13 | };
14 |
--------------------------------------------------------------------------------
/fe/src/graphql/notification/modifyNotification.ts:
--------------------------------------------------------------------------------
1 | import gql from 'graphql-tag';
2 |
3 | const CONFIRM_NOTIFICATION = gql`
4 | mutation($id: String) {
5 | update_notification(id: $id) {
6 | response
7 | }
8 | }
9 | `;
10 |
11 | export default CONFIRM_NOTIFICATION;
12 |
--------------------------------------------------------------------------------
/fe/src/graphql/tweet/addTweet.ts:
--------------------------------------------------------------------------------
1 | import gql from 'graphql-tag';
2 |
3 | export const ADD_BASIC_TWEET = gql`
4 | mutation($content: String!, $imgUrlList: [String]) {
5 | tweet: add_basic_tweet(content: $content, img_url_list: $imgUrlList) {
6 | content
7 | author_id
8 | }
9 | }
10 | `;
11 |
12 | export const ADD_REPLY_TWEET = gql`
13 | mutation($content: String!, $imgUrlList: [String], $parentId: String!) {
14 | tweet: add_reply_tweet(content: $content, img_url_list: $imgUrlList, parent_id: $parentId) {
15 | content
16 | author_id
17 | }
18 | }
19 | `;
20 |
21 | export const ADD_RETWEET = gql`
22 | mutation($content: String, $retweetId: String!) {
23 | tweet: add_retweet(content: $content, retweet_id: $retweetId) {
24 | content
25 | author_id
26 | }
27 | }
28 | `;
29 |
--------------------------------------------------------------------------------
/fe/src/graphql/tweet/deleteTweet.ts:
--------------------------------------------------------------------------------
1 | import gql from 'graphql-tag';
2 |
3 | const DELETE_TWEET = gql`
4 | mutation($tweetId: String!) {
5 | result: delete_tweet(tweet_id: $tweetId) {
6 | response
7 | }
8 | }
9 | `;
10 |
11 | export default DELETE_TWEET;
12 |
--------------------------------------------------------------------------------
/fe/src/graphql/tweet/index.ts:
--------------------------------------------------------------------------------
1 | import { ADD_BASIC_TWEET, ADD_REPLY_TWEET, ADD_RETWEET } from './addTweet';
2 | import {
3 | GET_TWEETLIST,
4 | GET_USER_TWEETLIST,
5 | GET_USER_ALL_TWEETLIST,
6 | GET_TWEET_DETAIL,
7 | GET_CHILD_TWEETLIST,
8 | GET_SEARCH_TWEETLIST,
9 | GET_HEART_TWEETLIST,
10 | } from './getTweet';
11 | import DELETE_TWEET from './deleteTweet';
12 | import { HEART_TWEET, UNHEART_TWEET } from './modifyTweet';
13 |
14 | export {
15 | ADD_BASIC_TWEET,
16 | ADD_REPLY_TWEET,
17 | ADD_RETWEET,
18 | GET_TWEETLIST,
19 | GET_USER_TWEETLIST,
20 | GET_USER_ALL_TWEETLIST,
21 | GET_TWEET_DETAIL,
22 | GET_CHILD_TWEETLIST,
23 | GET_SEARCH_TWEETLIST,
24 | GET_HEART_TWEETLIST,
25 | DELETE_TWEET,
26 | HEART_TWEET,
27 | UNHEART_TWEET,
28 | };
29 |
--------------------------------------------------------------------------------
/fe/src/graphql/tweet/modifyTweet.ts:
--------------------------------------------------------------------------------
1 | import gql from 'graphql-tag';
2 |
3 | export const UNHEART_TWEET = gql`
4 | mutation($tweet_id: String!) {
5 | tweet: unheart_tweet(tweet_id: $tweet_id) {
6 | content
7 | }
8 | }
9 | `;
10 |
11 | export const HEART_TWEET = gql`
12 | mutation($tweet_id: String!) {
13 | tweet: heart_tweet(tweet_id: $tweet_id) {
14 | content
15 | }
16 | }
17 | `;
18 |
--------------------------------------------------------------------------------
/fe/src/graphql/user/addUser.ts:
--------------------------------------------------------------------------------
1 | import gql from 'graphql-tag';
2 |
3 | const ADD_USER = gql`
4 | mutation($userId: String!, $name: String!, $password: String!) {
5 | response: create_user(user_id: $userId, name: $name, password: $password) {
6 | response
7 | }
8 | }
9 | `;
10 |
11 | export default ADD_USER;
12 |
--------------------------------------------------------------------------------
/fe/src/graphql/user/getUser.ts:
--------------------------------------------------------------------------------
1 | import gql from 'graphql-tag';
2 |
3 | export const GET_MYINFO = gql`
4 | query {
5 | myProfile: my_info {
6 | user_id
7 | name
8 | comment
9 | profile_img_url
10 | following_id_list
11 | heart_tweet_id_list
12 | lastest_notification_id
13 | }
14 | }
15 | `;
16 |
17 | export const GET_USER_DETAIL = gql`
18 | query($userId: String) {
19 | user: user_info(user_id: $userId) {
20 | _id
21 | user_id
22 | name
23 | comment
24 | profile_img_url
25 | background_img_url
26 | following_id_list
27 | }
28 | followerCount: follower_count(user_id: $userId) {
29 | count
30 | }
31 | }
32 | `;
33 |
34 | export const GET_FOLLOWER_LIST = gql`
35 | query($userId: String, $oldestUserId: String) {
36 | list: follower_list(user_id: $userId, oldest_user_id: $oldestUserId) {
37 | _id
38 | user_id
39 | name
40 | comment
41 | profile_img_url
42 | }
43 | }
44 | `;
45 |
46 | export const GET_FOLLOWING_LIST = gql`
47 | query($userId: String, $oldestUserId: String) {
48 | list: following_list(user_id: $userId, oldest_user_id: $oldestUserId) {
49 | _id
50 | user_id
51 | name
52 | comment
53 | profile_img_url
54 | }
55 | }
56 | `;
57 |
58 | export const GET_SEARCH_USERLIST = gql`
59 | query($searchWord: String!, $oldestUserId: String) {
60 | searchList: search_user_list(search_word: $searchWord, oldest_user_id: $oldestUserId) {
61 | _id
62 | user_id
63 | name
64 | comment
65 | profile_img_url
66 | }
67 | }
68 | `;
69 |
70 | export const GET_HEART_USERLIST = gql`
71 | query($tweetId: String, $oldestUserId: String) {
72 | userList: heart_user_list(tweet_id: $tweetId, oldest_user_id: $oldestUserId) {
73 | user_id
74 | name
75 | profile_img_url
76 | }
77 | }
78 | `;
79 |
80 | export const GET_RETWEET_USERLIST = gql`
81 | query($tweetId: String, $oldestUserId: String) {
82 | userList: retweet_user_list(tweet_id: $tweetId, oldest_user_id: $oldestUserId) {
83 | user_id
84 | name
85 | profile_img_url
86 | }
87 | }
88 | `;
89 |
--------------------------------------------------------------------------------
/fe/src/graphql/user/index.ts:
--------------------------------------------------------------------------------
1 | import ADD_USER from './addUser';
2 | import {
3 | GET_MYINFO,
4 | GET_USER_DETAIL,
5 | GET_FOLLOWER_LIST,
6 | GET_FOLLOWING_LIST,
7 | GET_SEARCH_USERLIST,
8 | GET_HEART_USERLIST,
9 | GET_RETWEET_USERLIST,
10 | } from './getUser';
11 | import { FOLLOW_USER, UNFOLLOW_USER, UPDATE_USER_INFO } from './modifyUser';
12 |
13 | export {
14 | ADD_USER,
15 | GET_MYINFO,
16 | GET_USER_DETAIL,
17 | GET_FOLLOWER_LIST,
18 | GET_FOLLOWING_LIST,
19 | GET_SEARCH_USERLIST,
20 | GET_HEART_USERLIST,
21 | GET_RETWEET_USERLIST,
22 | FOLLOW_USER,
23 | UNFOLLOW_USER,
24 | UPDATE_USER_INFO,
25 | };
26 |
--------------------------------------------------------------------------------
/fe/src/graphql/user/modifyUser.ts:
--------------------------------------------------------------------------------
1 | import gql from 'graphql-tag';
2 |
3 | export const FOLLOW_USER = gql`
4 | mutation($follow_user_id: String!) {
5 | user: follow_user(follow_user_id: $follow_user_id) {
6 | user_id
7 | }
8 | }
9 | `;
10 |
11 | export const UNFOLLOW_USER = gql`
12 | mutation($unfollow_user_id: String!) {
13 | user: unfollow_user(unfollow_user_id: $unfollow_user_id) {
14 | user_id
15 | }
16 | }
17 | `;
18 |
19 | export const UPDATE_USER_INFO = gql`
20 | mutation($name: String!, $comment: String) {
21 | update_user_info(name: $name, comment: $comment) {
22 | response
23 | }
24 | }
25 | `;
26 |
--------------------------------------------------------------------------------
/fe/src/hooks/index.ts:
--------------------------------------------------------------------------------
1 | import useDisplay from './useDisplay';
2 | import useDisplayWithShallow from './useDisplayWithShallow';
3 | import useHeartState from './useHeartState';
4 | import useInfiniteScroll from './useInfiniteScroll';
5 | import useMyInfo from './useMyInfo';
6 | import useOnTextChange from './useOnTextChange';
7 | import useUserState from './useUserState';
8 | import useApollo from './useApollo';
9 | import useTypeRouter from './useTypeRouter';
10 | import useDataWithInfiniteScroll from './useDataWithInfiniteScroll';
11 | import useHomeTweetListInfiniteScroll from './useHomeTweetListInfiniteScroll';
12 |
13 | export {
14 | useDisplay,
15 | useDisplayWithShallow,
16 | useHeartState,
17 | useInfiniteScroll,
18 | useMyInfo,
19 | useOnTextChange,
20 | useUserState,
21 | useApollo,
22 | useTypeRouter,
23 | useDataWithInfiniteScroll,
24 | useHomeTweetListInfiniteScroll,
25 | };
26 |
--------------------------------------------------------------------------------
/fe/src/hooks/useApollo.ts:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react';
2 | import { NormalizedCacheObject } from '@apollo/client';
3 | import { initializeApollo } from '@libs';
4 |
5 | const useApollo = (initialState: NormalizedCacheObject) => {
6 | const store = useMemo(() => initializeApollo(initialState), [initialState]);
7 | return store;
8 | };
9 |
10 | export default useApollo;
11 |
--------------------------------------------------------------------------------
/fe/src/hooks/useDataWithInfiniteScroll.ts:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import { useQuery } from '@apollo/client';
3 | import { DocumentNode } from 'graphql';
4 | import { useInfiniteScroll } from '@hooks';
5 |
6 | interface Props {
7 | variableTarget: string;
8 | variableValue?: string | string[];
9 | moreVariableTarget: string;
10 | dataTarget: string;
11 | updateQuery: DocumentNode;
12 | fetchMoreEl: React.RefObject;
13 | }
14 |
15 | const useDataWithInfiniteScroll = ({
16 | variableTarget,
17 | variableValue,
18 | moreVariableTarget,
19 | dataTarget,
20 | updateQuery,
21 | fetchMoreEl,
22 | }: Props): [
23 | any,
24 | React.Dispatch>,
25 | boolean,
26 | React.Dispatch>,
27 | ] => {
28 | const queryVariable = { variables: { [variableTarget]: variableValue } };
29 | const { data, fetchMore } = useQuery(updateQuery, queryVariable);
30 | const { _id: bottomId } = data?.[dataTarget][data?.[dataTarget].length - 1] || {};
31 | const [intersecting, setIntersecting, loadFinished, setLoadFinished] = useInfiniteScroll(
32 | fetchMoreEl,
33 | );
34 |
35 | useEffect(() => {
36 | const asyncEffect = async () => {
37 | if (!intersecting || !fetchMore) return;
38 | const { data: fetchMoreData }: any = await fetchMore({
39 | variables: { [moreVariableTarget]: bottomId },
40 | });
41 | if (!fetchMoreData) setIntersecting(false);
42 | if (fetchMoreData[dataTarget].length < 20) setLoadFinished(true);
43 | };
44 | asyncEffect();
45 | }, [intersecting]);
46 |
47 | return [data, setIntersecting, loadFinished, setLoadFinished];
48 | };
49 |
50 | export default useDataWithInfiniteScroll;
51 |
--------------------------------------------------------------------------------
/fe/src/hooks/useDisplayWithShallow.ts:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { useTypeRouter } from '@hooks';
3 |
4 | const useDisplayWithShallow = (routeName: string): [boolean, () => void, () => void] => {
5 | const { type, router } = useTypeRouter();
6 | const tweetId = type ? type[0] : '';
7 | const currentRoute = type ? type[1] : '';
8 | const [display, setDisplay] = useState(currentRoute === routeName);
9 |
10 | const onOpenModal = () => {
11 | router.replace(`/status/[[...type]]`, `/status/${tweetId}/${routeName}`, {
12 | shallow: true,
13 | });
14 | setDisplay(true);
15 | };
16 |
17 | const onCloseModal = () => {
18 | router.replace(`/status/[[...type]]/${routeName}`, `/status/${tweetId}`, {
19 | shallow: true,
20 | });
21 | setDisplay(false);
22 | };
23 |
24 | return [display, onOpenModal, onCloseModal];
25 | };
26 |
27 | export default useDisplayWithShallow;
28 |
--------------------------------------------------------------------------------
/fe/src/hooks/useHeartState.ts:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useState, useEffect } from 'react';
2 | import { DocumentNode } from 'graphql';
3 | import { useQuery, useMutation, ApolloCache } from '@apollo/client';
4 | import { GET_MYINFO } from '@graphql/user';
5 | import { HEART_TWEET, UNHEART_TWEET, GET_TWEETLIST } from '@graphql/tweet';
6 | import { TweetType, UserType } from '@types';
7 | import { binarySearch } from '@libs';
8 |
9 | const getIsHeart = (tweet: TweetType, myProfile: UserType) => {
10 | if (myProfile?.heart_tweet_id_list.includes(tweet?._id)) return true;
11 | return false;
12 | };
13 |
14 | const useHeartState = (
15 | tweet: TweetType,
16 | updateQuery: { query: DocumentNode; variables?: {}; object?: boolean },
17 | ): [boolean, () => Promise, () => Promise] => {
18 | const { data } = useQuery(GET_MYINFO);
19 | const [isHeart, setIsHeart] = useState(getIsHeart(tweet, data?.myProfile));
20 | const [state, setState] = useState(false);
21 |
22 | const [heartTweet] = useMutation(HEART_TWEET);
23 | const [unheartTweet] = useMutation(UNHEART_TWEET);
24 | const setHeartTweet = () => {
25 | setIsHeart(true);
26 | };
27 |
28 | const setUnheartTweet = () => {
29 | setIsHeart(false);
30 | };
31 |
32 | const updateCache = (cache: ApolloCache, type: string) => {
33 | let source;
34 | if (updateQuery.object) {
35 | source = { ...tweet };
36 | if (type === 'heart') {
37 | const number = source.heart_user_number + 1;
38 | source = { ...source, heart_user_number: number };
39 | } else {
40 | const number = source.heart_user_number - 1;
41 | source = { ...source, heart_user_number: number };
42 | }
43 | } else {
44 | const res: any = cache.readQuery({
45 | query: updateQuery.query,
46 | variables: updateQuery.variables || {},
47 | });
48 | source = [...res.tweetList];
49 | const idx = binarySearch(source, tweet._id);
50 | if (idx === -1) return;
51 | let number;
52 | if (type === 'heart') number = source[idx].heart_user_number + 1;
53 | else number = source[idx].heart_user_number - 1;
54 | source[idx] = {
55 | ...source[idx],
56 | heart_user_number: number,
57 | };
58 | }
59 | cache.writeQuery({
60 | query: updateQuery.query,
61 | variables: updateQuery.variables || {},
62 | data: { tweetList: source },
63 | });
64 | cache.evict({ id: 'ROOT_QUERY', fieldName: 'heart_user_list' });
65 | };
66 |
67 | const onClickHeart = async () => {
68 | if (!state) {
69 | setState(true);
70 | await heartTweet({
71 | variables: { tweet_id: tweet._id },
72 | update: (cache) => {
73 | const userInfo = cache.readQuery<{ myProfile: UserType }>({ query: GET_MYINFO });
74 | if (userInfo) {
75 | cache.writeQuery({
76 | query: GET_MYINFO,
77 | data: {
78 | myProfile: {
79 | ...userInfo.myProfile,
80 | heart_tweet_id_list: [...userInfo.myProfile.heart_tweet_id_list, tweet._id],
81 | },
82 | },
83 | });
84 | }
85 | updateCache(cache, 'heart');
86 | },
87 | });
88 | setHeartTweet();
89 | setState(false);
90 | }
91 | };
92 |
93 | const onClickUnheart = async () => {
94 | if (!state) {
95 | setState(true);
96 | await unheartTweet({
97 | variables: { tweet_id: tweet._id },
98 | update: (cache) => {
99 | const userInfo = cache.readQuery<{ myProfile: UserType }>({
100 | query: GET_MYINFO,
101 | });
102 | if (userInfo) {
103 | const arr = [...userInfo.myProfile.heart_tweet_id_list];
104 | const index = arr.indexOf(tweet._id);
105 | arr.splice(index, 1);
106 | cache.writeQuery({
107 | query: GET_MYINFO,
108 | data: {
109 | myProfile: {
110 | ...userInfo.myProfile,
111 | heart_tweet_id_list: arr,
112 | },
113 | },
114 | });
115 | }
116 | updateCache(cache, 'unheart');
117 | },
118 | });
119 | setUnheartTweet();
120 | setState(false);
121 | }
122 | };
123 |
124 | useEffect(() => {
125 | if (tweet) setIsHeart(getIsHeart(tweet, data?.myProfile));
126 | }, [tweet]);
127 |
128 | return [isHeart, onClickHeart, onClickUnheart];
129 | };
130 |
131 | export default useHeartState;
132 |
--------------------------------------------------------------------------------
/fe/src/hooks/useHomeTweetListInfiniteScroll.ts:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import { useQuery } from '@apollo/client';
3 | import { useInfiniteScroll } from '@hooks';
4 | import { GET_TWEETLIST } from '@graphql/tweet';
5 |
6 | const THIRTY_SECONDS = 30 * 1000;
7 |
8 | const useHomeTweetInfiniteScroll = (
9 | fetchMoreEl: React.RefObject,
10 | ): [
11 | any,
12 | React.Dispatch>,
13 | boolean,
14 | React.Dispatch>,
15 | ] => {
16 | const { data, fetchMore } = useQuery(GET_TWEETLIST);
17 | const { _id: topTweetId } = data?.tweetList[0] || {};
18 | const { _id: bottomTweetId } = data?.tweetList[data?.tweetList.length - 1] || {};
19 | useQuery(GET_TWEETLIST, {
20 | variables: { latestTweetId: topTweetId },
21 | pollInterval: THIRTY_SECONDS,
22 | });
23 |
24 | const [intersecting, setIntersecting, loadFinished, setLoadFinished] = useInfiniteScroll(
25 | fetchMoreEl,
26 | );
27 |
28 | useEffect(() => {
29 | const asyncEffect = async () => {
30 | if (!intersecting || loadFinished || !fetchMore) return;
31 | const { data: fetchMoreData } = await fetchMore({
32 | variables: { oldestTweetId: bottomTweetId },
33 | });
34 | if (!fetchMoreData) setIntersecting(false);
35 | if (fetchMoreData.tweetList.length < 20) setLoadFinished(true);
36 | };
37 | asyncEffect();
38 | }, [intersecting]);
39 |
40 | return [data, setIntersecting, loadFinished, setLoadFinished];
41 | };
42 |
43 | export default useHomeTweetInfiniteScroll;
44 |
--------------------------------------------------------------------------------
/fe/src/hooks/useInfiniteScroll.ts:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useState, useRef, useEffect } from 'react';
2 |
3 | const useInfiniteScroll = (
4 | targetEl: React.RefObject,
5 | ): [
6 | boolean,
7 | React.Dispatch>,
8 | boolean,
9 | React.Dispatch>,
10 | ] => {
11 | const observerRef = useRef(null);
12 | const [isIntersecting, setIntersecting] = useState(false);
13 | const [loadFinished, setLoadFinished] = useState(false);
14 |
15 | const getObserver = useCallback((): IntersectionObserver => {
16 | if (!observerRef.current) {
17 | observerRef.current = new IntersectionObserver(
18 | (entries) => {
19 | const intersecting = entries.some((entry) => entry.isIntersecting);
20 | setIntersecting(intersecting);
21 | },
22 | { root: null, rootMargin: '0px', threshold: 0 },
23 | );
24 | }
25 | return observerRef.current;
26 | }, [observerRef.current]);
27 |
28 | const stopObserving = useCallback(() => {
29 | getObserver().disconnect();
30 | }, []);
31 |
32 | useEffect(() => {
33 | if (targetEl.current) getObserver().observe(targetEl.current);
34 | return stopObserving;
35 | }, [targetEl.current]);
36 |
37 | useEffect(() => {
38 | if (loadFinished) stopObserving();
39 | }, [loadFinished]);
40 |
41 | return [isIntersecting, setIntersecting, loadFinished, setLoadFinished];
42 | };
43 |
44 | export default useInfiniteScroll;
45 |
--------------------------------------------------------------------------------
/fe/src/hooks/useMyInfo.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@apollo/client';
2 | import { GET_MYINFO } from '@graphql/user';
3 |
4 | const useMyInfo = () => {
5 | const { loading, error, data } = useQuery(GET_MYINFO);
6 | if (loading) return loading;
7 | if (error) return error;
8 | return data || {};
9 | };
10 |
11 | export default useMyInfo;
12 |
--------------------------------------------------------------------------------
/fe/src/hooks/useTypeRouter.ts:
--------------------------------------------------------------------------------
1 | import { NextRouter, useRouter } from 'next/router';
2 |
3 | const useTypeRouter = () => {
4 | const router = useRouter();
5 | const { type, userId } = router.query;
6 | return { type, userId, router };
7 | };
8 | export default useTypeRouter;
9 |
--------------------------------------------------------------------------------
/fe/src/hooks/useUserState.ts:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import { useQuery, useMutation, ApolloCache, DocumentNode } from '@apollo/client';
3 | import { GET_MYINFO, FOLLOW_USER, UNFOLLOW_USER, GET_USER_DETAIL } from '@graphql/user';
4 | import { UserType } from '@types';
5 |
6 | const getUserType = (user: UserType, myProfile: UserType) => {
7 | if (myProfile?.user_id === user?.user_id) return 'me';
8 | if (myProfile?.following_id_list.includes(user?.user_id)) return 'followUser';
9 | return 'unfollowUser';
10 | };
11 |
12 | const useUserState = (
13 | user: UserType,
14 | updateQuery?: { query: DocumentNode; variables?: {} },
15 | ): [string, () => void, () => void] => {
16 | const { data } = useQuery(GET_MYINFO);
17 | const [userState, setUserState] = useState(getUserType(user, data?.myProfile));
18 |
19 | const [followUser] = useMutation(FOLLOW_USER);
20 | const [unfollowUser] = useMutation(UNFOLLOW_USER);
21 |
22 | const setFollowUser = async () => {
23 | setUserState('followUser');
24 | };
25 | const setUnfollowUser = () => {
26 | setUserState('unfollowUser');
27 | };
28 |
29 | const updateCache = (cache: ApolloCache, type: string) => {
30 | if (updateQuery) {
31 | const userInfo: { followerCount: { count: number } } = cache.readQuery({
32 | query: updateQuery.query,
33 | variables: updateQuery.variables,
34 | }) || { followerCount: { count: 0 } };
35 |
36 | let number;
37 | if (type === 'follow') number = userInfo.followerCount.count + 1;
38 | else number = userInfo.followerCount.count - 1;
39 | cache.writeQuery({
40 | query: updateQuery.query,
41 | variables: updateQuery.variables,
42 | data: {
43 | followerCount: {
44 | count: number,
45 | },
46 | },
47 | });
48 | } else cache.evict({ id: 'ROOT_QUERY', fieldName: 'my_info' });
49 | };
50 |
51 | const onClickFollow = () => {
52 | setFollowUser();
53 | setTimeout(() => {
54 | followUser({
55 | variables: { follow_user_id: user.user_id },
56 | update: (cache) => {
57 | updateCache(cache, 'follow');
58 | },
59 | });
60 | });
61 | };
62 |
63 | const onClickUnfollow = () => {
64 | setUnfollowUser();
65 | setTimeout(() => {
66 | unfollowUser({
67 | variables: { unfollow_user_id: user.user_id },
68 | update: (cache) => {
69 | updateCache(cache, 'unfollow');
70 | },
71 | });
72 | });
73 | };
74 |
75 | useEffect(() => {
76 | if (user) setUserState(getUserType(user, data?.myProfile));
77 | }, [user]);
78 |
79 | return [userState, onClickFollow, onClickUnfollow];
80 | };
81 |
82 | export default useUserState;
83 |
--------------------------------------------------------------------------------
/fe/src/libs/apolloClient.ts:
--------------------------------------------------------------------------------
1 | import { ApolloClient, InMemoryCache, NormalizedCacheObject } from '@apollo/client';
2 | import { createUploadLink } from 'apollo-upload-client';
3 |
4 | let apolloClient: ApolloClient;
5 |
6 | const API_SERVER_URL =
7 | process.env.NODE_ENV === 'development'
8 | ? process.env.DEV_API_SERVER_URL
9 | : process.env.PRO_API_SERVER_URL;
10 |
11 | const httpLink = createUploadLink({
12 | uri: API_SERVER_URL,
13 | credentials: 'include',
14 | });
15 |
16 | const mergeItems = (a = [], b = []) => {
17 | return Array.from(new Set([...a, ...b].map((m: any) => m.__ref))).map((r) => ({ __ref: r }));
18 | };
19 |
20 | const tweetPolicies = {
21 | read(existing: any) {
22 | return existing;
23 | },
24 | merge(existing = [], incoming = [], { args: { oldest_tweet_id, latest_tweet_id } }: any) {
25 | if (oldest_tweet_id) return mergeItems(existing, incoming);
26 | if (latest_tweet_id) return mergeItems(incoming, existing);
27 | return incoming;
28 | },
29 | };
30 |
31 | const userPolicies = {
32 | read(existing: any) {
33 | return existing;
34 | },
35 | merge(existing = [], incoming = [], { args }: any) {
36 | return mergeItems(existing, incoming);
37 | },
38 | };
39 |
40 | const notificationPolicies = {
41 | read(existing: any) {
42 | return existing;
43 | },
44 | merge(existing = [], incoming = [], { args: { oldest_notification_id } }: any) {
45 | if (!oldest_notification_id) return mergeItems(incoming, existing);
46 | return mergeItems(existing, incoming);
47 | },
48 | };
49 |
50 | const createApolloClient = () =>
51 | new ApolloClient({
52 | link: httpLink, // api 서버 url
53 | cache: new InMemoryCache({
54 | typePolicies: {
55 | Query: {
56 | fields: {
57 | following_tweet_list: tweetPolicies,
58 | child_tweet_list: tweetPolicies,
59 | heart_tweet_list: tweetPolicies,
60 | search_tweet_list: tweetPolicies,
61 | user_tweet_list: tweetPolicies,
62 | user_all_tweet_list: tweetPolicies,
63 | search_user_list: userPolicies,
64 | following_list: userPolicies,
65 | follower_list: userPolicies,
66 | notification_list: notificationPolicies,
67 | notification_mention_list: notificationPolicies,
68 | },
69 | },
70 | },
71 | }),
72 | });
73 |
74 | const initializeApollo = (initialState: NormalizedCacheObject = {}) => {
75 | const _apolloClient = apolloClient || createApolloClient();
76 |
77 | if (initialState) {
78 | const existingCache = _apolloClient.extract();
79 | const data = Object.assign(existingCache, initialState);
80 | _apolloClient.cache.restore(data);
81 | }
82 | if (typeof window === 'undefined') return _apolloClient;
83 | if (!apolloClient) apolloClient = _apolloClient;
84 | return _apolloClient;
85 | };
86 |
87 | const recreateApollo = () => {
88 | apolloClient = createApolloClient();
89 | };
90 |
91 | export { initializeApollo, recreateApollo };
92 |
--------------------------------------------------------------------------------
/fe/src/libs/authProvider.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent, ReactChild } from 'react';
2 | import { useQuery } from '@apollo/client';
3 | import { useRouter } from 'next/router';
4 | import { Loading } from '@molecules';
5 | import { GET_MYINFO } from '@graphql/user';
6 |
7 | interface Props {
8 | children: ReactChild;
9 | }
10 |
11 | const AuthProvider: FunctionComponent = ({ children }) => {
12 | const router = useRouter();
13 | const { data, error } = useQuery(GET_MYINFO);
14 |
15 | if (router.pathname.includes('login') || router.pathname.includes('callback'))
16 | return <>{children}>;
17 |
18 | if (data) return <>{children}>;
19 | if (error) router.push('/login');
20 | return ;
21 | };
22 |
23 | export default AuthProvider;
24 |
--------------------------------------------------------------------------------
/fe/src/libs/index.tsx:
--------------------------------------------------------------------------------
1 | import { getJSXwithUserState, makeTimeText, binarySearch, getJWTFromBrowser } from './utility';
2 | import { initializeApollo, recreateApollo } from './apolloClient';
3 | import AuthProvider from './authProvider';
4 |
5 | export {
6 | getJSXwithUserState,
7 | makeTimeText,
8 | initializeApollo,
9 | recreateApollo,
10 | AuthProvider,
11 | binarySearch,
12 | getJWTFromBrowser,
13 | };
14 |
--------------------------------------------------------------------------------
/fe/src/libs/utility.ts:
--------------------------------------------------------------------------------
1 | import TimeAgo from 'javascript-time-ago';
2 | import Cookies from 'cookies';
3 |
4 | // English.
5 | import en from 'javascript-time-ago/locale/en';
6 |
7 | TimeAgo.addLocale(en);
8 | const timeAgo = new TimeAgo('en-US');
9 |
10 | interface JSX {}
11 |
12 | const getJSXwithUserState = (userState: string, meJSX: JSX, followJSX: JSX, unfollowJSX: JSX) => {
13 | if (userState === 'me') return meJSX;
14 | if (userState === 'followUser') return followJSX;
15 | return unfollowJSX;
16 | };
17 |
18 | const makeTimeText = (pastTime: string) => {
19 | const pastDatetime = new Date(pastTime);
20 | const timeString = timeAgo.format(pastDatetime, 'round');
21 | return timeString;
22 | };
23 |
24 | const binarySearch = (arr: any, id: string) => {
25 | let left = 0;
26 | let right = arr.length - 1;
27 | while (left <= right) {
28 | const mid = Math.floor((left + right) / 2);
29 | if (arr[mid]._id < id) right = mid - 1;
30 | else if (arr[mid]._id > id) left = mid + 1;
31 | else return mid;
32 | }
33 | return -1;
34 | };
35 |
36 | const getJWTFromBrowser = (req: any, res: any) => {
37 | const cookies = new Cookies(req, res);
38 | return cookies.get('jwt');
39 | };
40 |
41 | export { getJSXwithUserState, makeTimeText, binarySearch, getJWTFromBrowser };
42 |
--------------------------------------------------------------------------------
/fe/src/pages/[userId]/[[...type]].tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent, useEffect, useRef } from 'react';
2 | import { GetServerSideProps } from 'next';
3 | import { TabBar, LoadingCircle } from '@molecules';
4 | import { PageLayout, TweetContainer, UserDetailContainer } from '@organisms';
5 | import { useDataWithInfiniteScroll, useTypeRouter } from '@hooks';
6 | import { initializeApollo, getJWTFromBrowser } from '@libs';
7 | import { GET_USER_TWEETLIST, GET_USER_ALL_TWEETLIST, GET_HEART_TWEETLIST } from '@graphql/tweet';
8 | import DETAIL_PAGE from '@graphql/custom';
9 | import { NoResult } from '@atoms';
10 | import { TweetType } from '@types';
11 |
12 | const getValue = (type?: string[] | string) => {
13 | if (!type || !type.length) return 'tweets';
14 | if (type[0] === 'tweets & replies') return 'tweets & replies';
15 | if (type[0] === 'likes') return 'likes';
16 | return 'tweets';
17 | };
18 |
19 | const UserDetail: FunctionComponent = () => {
20 | const apolloClient = initializeApollo();
21 | const { type, userId, router } = useTypeRouter();
22 | const value = getValue(type);
23 |
24 | const fetchMoreEl = useRef(null);
25 | const keyValue = {
26 | tweets: {
27 | variableTarget: 'userId',
28 | variableValue: userId,
29 | moreVariableTarget: 'oldestTweetId',
30 | dataTarget: 'tweetList',
31 | updateQuery: GET_USER_TWEETLIST,
32 | fetchMoreEl,
33 | },
34 | 'tweets & replies': {
35 | variableTarget: 'userId',
36 | variableValue: userId,
37 | moreVariableTarget: 'oldestTweetId',
38 | dataTarget: 'tweetList',
39 | updateQuery: GET_USER_ALL_TWEETLIST,
40 | fetchMoreEl,
41 | },
42 | likes: {
43 | variableTarget: 'userId',
44 | variableValue: userId,
45 | moreVariableTarget: 'oldestTweetId',
46 | dataTarget: 'tweetList',
47 | updateQuery: GET_HEART_TWEETLIST,
48 | fetchMoreEl,
49 | },
50 | };
51 | const [data, setIntersecting, loadFinished, setLoadFinished] = useDataWithInfiniteScroll(
52 | keyValue[value],
53 | );
54 |
55 | const onClick = (e: React.SyntheticEvent) => {
56 | const target = e.target as HTMLInputElement;
57 | let newValue = target.textContent;
58 | if (newValue !== value) {
59 | if (newValue === 'tweets') newValue = '';
60 | router.replace('/[userId]/[type]', `/${userId}/${newValue}`, { shallow: true });
61 | apolloClient.cache.evict({ id: 'ROOT_QUERY', fieldName: 'user_all_tweet_list' });
62 | apolloClient.cache.evict({ id: 'ROOT_QUERY', fieldName: 'heart_tweet_list' });
63 | apolloClient.cache.evict({ id: 'ROOT_QUERY', fieldName: 'user_tweet_list' });
64 | setLoadFinished(false);
65 | setIntersecting(false);
66 | }
67 | };
68 |
69 | return (
70 |
71 |
72 |
77 |
78 | {data ? (
79 | data.tweetList?.map((tweet: TweetType, index: number) => (
80 |
88 | ))
89 | ) : (
90 | <>>
91 | )}
92 | {data?.tweetList?.length === 0 ? (
93 |
94 | ) : null}
95 |
96 |
97 |
98 | );
99 | };
100 |
101 | export default UserDetail;
102 |
103 | export const getServerSideProps: GetServerSideProps<{}, {}> = async (ctx) => {
104 | const jwt = getJWTFromBrowser(ctx.req, ctx.res);
105 | const apolloClient = initializeApollo();
106 | const { userId } = ctx.query || {};
107 |
108 | const { data } = await apolloClient.query({
109 | query: DETAIL_PAGE,
110 | variables: { userId },
111 | context: {
112 | headers: { cookie: `jwt=${jwt}` },
113 | },
114 | });
115 | if (!data.user) {
116 | return {
117 | notFound: true,
118 | };
119 | }
120 | const initialState = apolloClient.cache.extract();
121 |
122 | return {
123 | props: {
124 | initialState,
125 | },
126 | };
127 | };
128 |
--------------------------------------------------------------------------------
/fe/src/pages/[userId]/follow/[[...type]].tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent, useRef } from 'react';
2 | import { GetServerSideProps } from 'next';
3 | import { TabBar, TitleSubText, ComponentLoading, LoadingCircle } from '@molecules';
4 | import { PageLayout, UserCard } from '@organisms';
5 | import { useTypeRouter, useDataWithInfiniteScroll } from '@hooks';
6 | import { GET_FOLLOWING_LIST, GET_FOLLOWER_LIST } from '@graphql/user';
7 | import { UserType } from '@types';
8 | import { getJWTFromBrowser, initializeApollo } from '@libs';
9 | import { NoResult } from '@atoms';
10 | import UserBox from './styled';
11 |
12 | const getValue = (type?: string[] | string) => {
13 | if (!type || !type.length) return 'follower';
14 | if (type[0] === 'following') return 'following';
15 | return 'follower';
16 | };
17 |
18 | const Follow: FunctionComponent = () => {
19 | const { type, userId, router } = useTypeRouter();
20 | const value = getValue(type);
21 | const fetchMoreEl = useRef(null);
22 |
23 | const keyValue = {
24 | follower: {
25 | variableTarget: 'userId',
26 | variableValue: userId,
27 | moreVariableTarget: 'oldestUserId',
28 | dataTarget: 'list',
29 | updateQuery: GET_FOLLOWER_LIST,
30 | fetchMoreEl,
31 | },
32 | following: {
33 | variableTarget: 'userId',
34 | variableValue: userId,
35 | moreVariableTarget: 'oldestUserId',
36 | dataTarget: 'list',
37 | updateQuery: GET_FOLLOWING_LIST,
38 | fetchMoreEl,
39 | },
40 | };
41 | const [data, setIntersecting, loadFinished, setLoadFinished] = useDataWithInfiniteScroll(
42 | keyValue[value],
43 | );
44 |
45 | const onClick = (e: React.SyntheticEvent) => {
46 | const target = e.target as HTMLInputElement;
47 | let newValue = target.textContent;
48 | if (newValue !== value) {
49 | if (newValue === 'follower') newValue = '';
50 | router.replace(`/[userId]/follow/[[...type]]`, `/${userId}/follow/${newValue}`, {
51 | shallow: true,
52 | });
53 | initializeApollo().cache.evict({ id: 'ROOT_QUERY', fieldName: 'following_list' });
54 | initializeApollo().cache.evict({ id: 'ROOT_QUERY', fieldName: 'follower_list' });
55 | setLoadFinished(false);
56 | setIntersecting(false);
57 | }
58 | };
59 |
60 | return (
61 |
62 |
63 |
64 |
65 |
66 |
67 | {data ? (
68 | data.list?.map((user: UserType, index: number) =>
69 | user.following_user ? (
70 |
71 | ) : (
72 |
73 | ),
74 | )
75 | ) : (
76 | <>>
77 | )}
78 | {data?.list?.length === 0 ? (
79 |
80 | ) : null}
81 |
82 |
83 |
84 | );
85 | };
86 |
87 | export default Follow;
88 |
89 | export const getServerSideProps: GetServerSideProps<{}, {}> = async (ctx) => {
90 | const jwt = getJWTFromBrowser(ctx.req, ctx.res);
91 | const { userId } = ctx.query || {};
92 |
93 | const apolloClient = initializeApollo();
94 | const result = await apolloClient.query({
95 | query: GET_FOLLOWER_LIST,
96 | variables: { userId },
97 | context: {
98 | headers: { cookie: `jwt=${jwt}` },
99 | },
100 | });
101 | if (!result) {
102 | return {
103 | notFound: true,
104 | };
105 | }
106 | const initialState = apolloClient.cache.extract();
107 |
108 | return {
109 | props: {
110 | initialState,
111 | },
112 | };
113 | };
114 |
--------------------------------------------------------------------------------
/fe/src/pages/[userId]/follow/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { Box } from '@material-ui/core';
3 |
4 | const UserBox = styled(Box)`
5 | padding: 5px;
6 | border-bottom: 1px solid rgb(235, 238, 240);
7 | `;
8 |
9 | export default UserBox;
10 |
--------------------------------------------------------------------------------
/fe/src/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/jsx-props-no-spreading */
2 | import React from 'react';
3 | import { ApolloProvider } from '@apollo/client';
4 | import { AppProps } from 'next/app';
5 | import { MuiThemeProvider, createMuiTheme } from '@material-ui/core/styles';
6 | import { AuthProvider } from '@libs';
7 | import { useApollo } from '@hooks';
8 | import '@styles/global.css';
9 |
10 | const theme = createMuiTheme({
11 | palette: {
12 | primary: {
13 | main: 'rgb(29, 161, 242)',
14 | contrastText: '#fff',
15 | },
16 | },
17 | });
18 |
19 | const App = ({ Component, pageProps }: AppProps) => {
20 | const apolloClient = useApollo(pageProps.initialState);
21 |
22 | return (
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | );
31 | };
32 |
33 | export default App;
34 |
--------------------------------------------------------------------------------
/fe/src/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/jsx-props-no-spreading */
2 | import React from 'react';
3 | import Document, { Html, Head, Main, NextScript, DocumentContext } from 'next/document';
4 | import { ServerStyleSheets } from '@material-ui/core/styles';
5 | import { ServerStyleSheet } from 'styled-components';
6 |
7 | export default class MyDocument extends Document {
8 | render() {
9 | return (
10 |
11 |
12 |
16 | Bwitter
17 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | );
29 | }
30 | }
31 |
32 | // `getInitialProps` belongs to `_document` (instead of `_app`),
33 | // it's compatible with server-side generation (SSG).
34 | MyDocument.getInitialProps = async (ctx: DocumentContext) => {
35 | // Resolution order
36 | //
37 | // On the server:
38 | // 1. app.getInitialProps
39 | // 2. page.getInitialProps
40 | // 3. document.getInitialProps
41 | // 4. app.render
42 | // 5. page.render
43 | // 6. document.render
44 | //
45 | // On the server with error:
46 | // 1. document.getInitialProps
47 | // 2. app.render
48 | // 3. page.render
49 | // 4. document.render
50 | //
51 | // On the client
52 | // 1. app.getInitialProps
53 | // 2. page.getInitialProps
54 | // 3. app.render
55 | // 4. page.render
56 |
57 | // Render app and page and get the context of the page with collected side effects.
58 | const sheets = new ServerStyleSheets();
59 | const sheet = new ServerStyleSheet();
60 | const originalRenderPage = ctx.renderPage;
61 |
62 | ctx.renderPage = () =>
63 | originalRenderPage({
64 | enhanceApp: (App) => (props) => sheet.collectStyles(sheets.collect()),
65 | });
66 |
67 | const initialProps = await Document.getInitialProps(ctx);
68 |
69 | return {
70 | ...initialProps,
71 | // Styles fragment is rendered after the app and page rendering finish.
72 | styles: [
73 | ...React.Children.toArray(initialProps.styles),
74 | sheets.getStyleElement(),
75 | sheet.getStyleElement(),
76 | ],
77 | };
78 | };
79 |
--------------------------------------------------------------------------------
/fe/src/pages/callback/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import { useMutation } from '@apollo/client';
3 | import { NextPageContext, NextPage } from 'next';
4 | import { useRouter } from 'next/router';
5 | import { Loading } from '@molecules';
6 | import { GITHUB_LOGIN } from '@graphql/auth';
7 | import { recreateApollo } from '@libs';
8 |
9 | interface Props {
10 | code: string | string[] | undefined;
11 | }
12 |
13 | const Callback: NextPage = ({ code }) => {
14 | const [login, { error, data }] = useMutation(GITHUB_LOGIN);
15 | const router = useRouter();
16 | useEffect(() => {
17 | login({ variables: { code } });
18 | }, []);
19 |
20 | if (error) router.push('/login');
21 | if (data) {
22 | recreateApollo();
23 | router.push('/');
24 | }
25 | return ;
26 | };
27 |
28 | Callback.getInitialProps = ({ query: { code } }: NextPageContext) => {
29 | return { code };
30 | };
31 | export default Callback;
32 |
--------------------------------------------------------------------------------
/fe/src/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent, useRef } from 'react';
2 | import { useMutation } from '@apollo/client';
3 | import { GetServerSideProps } from 'next';
4 | import { PageLayout, TweetContainer, NewTweetContainer } from '@organisms';
5 | import { useHomeTweetListInfiniteScroll } from '@hooks';
6 | import { GET_TWEETLIST, ADD_BASIC_TWEET } from '@graphql/tweet';
7 | import { TweetType } from '@types';
8 | import { initializeApollo, getJWTFromBrowser } from '@libs';
9 | import { LoadingCircle } from '@molecules';
10 | import { NoResult } from '@atoms';
11 | import HomeBox from './styled';
12 |
13 | const Home: FunctionComponent = () => {
14 | const [addBasicTweet] = useMutation(ADD_BASIC_TWEET);
15 | const fetchMoreEl = useRef(null);
16 | const [data, , loadFinished] = useHomeTweetListInfiniteScroll(fetchMoreEl);
17 |
18 | return (
19 |
20 | Home
21 |
22 |
23 | {data?.tweetList?.map((tweet: TweetType, index: number) => (
24 |
25 | ))}
26 | {data?.tweetList?.length === 0 ? (
27 |
28 | ) : null}
29 |
30 |
31 |
32 | );
33 | };
34 |
35 | export default Home;
36 |
37 | export const getServerSideProps: GetServerSideProps<{}, {}> = async (ctx) => {
38 | const apolloClient = initializeApollo();
39 | const jwt = getJWTFromBrowser(ctx.req, ctx.res);
40 | await apolloClient.query({
41 | query: GET_TWEETLIST,
42 | context: {
43 | headers: { cookie: `jwt=${jwt}` },
44 | },
45 | });
46 | const initialState = apolloClient.cache.extract();
47 |
48 | return {
49 | props: {
50 | initialState,
51 | },
52 | };
53 | };
54 |
--------------------------------------------------------------------------------
/fe/src/pages/login/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent } from 'react';
2 | import { Footer } from '@molecules';
3 | import { LoginLeftSection, LoginRightSection } from '@organisms';
4 | import Container from './styled';
5 |
6 | const Login: FunctionComponent = () => {
7 | return (
8 | <>
9 |
10 |
11 |
12 |
13 |
14 | >
15 | );
16 | };
17 |
18 | export default Login;
19 |
--------------------------------------------------------------------------------
/fe/src/pages/notifications/[[...type]].tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent, useEffect, useRef } from 'react';
2 | import { useMutation } from '@apollo/client';
3 | import { GetServerSideProps } from 'next';
4 | import { TabBar, LoadingCircle } from '@molecules';
5 | import { PageLayout, NotificationContainer } from '@organisms';
6 | import { useDataWithInfiniteScroll, useTypeRouter } from '@hooks';
7 | import { initializeApollo, getJWTFromBrowser } from '@libs';
8 | import { GET_MYINFO } from '@graphql/user';
9 | import { NotificationType, UserType } from '@types';
10 | import { NoResult } from '@atoms';
11 | import {
12 | GET_NOTIFICATION_LIST,
13 | GET_MENTION_NOTIFICATION_LIST,
14 | CONFIRM_NOTIFICATION,
15 | } from '@graphql/notification';
16 |
17 | const getValue = (type?: string[] | string) => {
18 | if (!type || !type.length) return 'all';
19 | if (type[0] === 'mention') return 'mention';
20 | return 'all';
21 | };
22 |
23 | const Notification: FunctionComponent = () => {
24 | const apolloClient = initializeApollo();
25 | const { type, router } = useTypeRouter();
26 | const value = getValue(type);
27 | const [mutate] = useMutation(CONFIRM_NOTIFICATION);
28 |
29 | const fetchMoreEl = useRef(null);
30 |
31 | const keyValue = {
32 | all: {
33 | variableTarget: '',
34 | variableValue: '',
35 | moreVariableTarget: 'id',
36 | dataTarget: 'notifications',
37 | updateQuery: GET_NOTIFICATION_LIST,
38 | fetchMoreEl,
39 | },
40 | mention: {
41 | variableTarget: '',
42 | variableValue: '',
43 | moreVariableTarget: 'id',
44 | dataTarget: 'notifications',
45 | updateQuery: GET_MENTION_NOTIFICATION_LIST,
46 | fetchMoreEl,
47 | },
48 | };
49 | const [data, setIntersecting, loadFinished, setLoadFinished] = useDataWithInfiniteScroll(
50 | keyValue[value],
51 | );
52 |
53 | const onClick = (e: React.SyntheticEvent) => {
54 | const target = e.target as HTMLInputElement;
55 | let newValue = target.textContent;
56 | if (newValue !== value) {
57 | if (newValue === 'all') newValue = '';
58 | router.replace('/notifications/[[...type]]', `/notifications/${newValue}`, { shallow: true });
59 | setLoadFinished(false);
60 | setIntersecting(false);
61 | }
62 | };
63 |
64 | useEffect(() => {
65 | const lastestNotification = data?.notifications[0];
66 | if (lastestNotification)
67 | mutate({
68 | variables: { id: lastestNotification._id },
69 | update: (cache) => {
70 | const updateData = cache.readQuery<{ myProfile: UserType }>({ query: GET_MYINFO });
71 | if (updateData) {
72 | if (
73 | updateData.myProfile.lastest_notification_id &&
74 | lastestNotification._id > updateData.myProfile.lastest_notification_id
75 | )
76 | cache.writeQuery({
77 | query: GET_MYINFO,
78 | data: {
79 | myProfile: {
80 | ...updateData.myProfile,
81 | lastest_notification_id: lastestNotification._id,
82 | },
83 | },
84 | });
85 | }
86 | },
87 | });
88 | }, [data?.notifications]);
89 |
90 | return (
91 |
92 |
93 | <>
94 | {data?.notifications.map((noti: NotificationType, index: number) => (
95 |
101 | ))}
102 | {data?.notifications?.length === 0 ? (
103 |
104 | ) : null}
105 | >
106 |
107 |
108 | );
109 | };
110 |
111 | export default Notification;
112 |
113 | export const getServerSideProps: GetServerSideProps<{}, {}> = async (ctx) => {
114 | const apolloClient = initializeApollo();
115 | const jwt = getJWTFromBrowser(ctx.req, ctx.res);
116 | const result = await apolloClient.query({
117 | query: GET_NOTIFICATION_LIST,
118 | context: {
119 | headers: { cookie: `jwt=${jwt}` },
120 | },
121 | });
122 | const initialState = apolloClient.cache.extract();
123 |
124 | return {
125 | props: {
126 | initialState,
127 | },
128 | };
129 | };
130 |
--------------------------------------------------------------------------------
/fe/src/pages/status/[[...type]].tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent, useEffect, useRef } from 'react';
2 | import { GetServerSideProps } from 'next';
3 | import { Loading, LoadingCircle } from '@molecules';
4 | import { PageLayout, TweetContainer, TweetDetailContainer } from '@organisms';
5 | import { useTypeRouter, useDataWithInfiniteScroll } from '@hooks';
6 | import { initializeApollo, getJWTFromBrowser } from '@libs';
7 | import { TweetType } from '@types';
8 | import { GET_CHILD_TWEETLIST, GET_TWEET_DETAIL } from '@graphql/tweet';
9 |
10 | const TweetDetail: FunctionComponent = () => {
11 | const { type } = useTypeRouter();
12 | const tweetId = type ? type[0] : '';
13 |
14 | const fetchMoreEl = useRef(null);
15 |
16 | const [data, , loadFinished] = useDataWithInfiniteScroll({
17 | variableTarget: 'tweetId',
18 | variableValue: tweetId,
19 | moreVariableTarget: 'oldestTweetId',
20 | dataTarget: 'tweetList',
21 | updateQuery: GET_CHILD_TWEETLIST,
22 | fetchMoreEl,
23 | });
24 |
25 | return (
26 |
27 |
28 | {data ? (
29 | data.tweetList?.map((tweet: TweetType, index: number) => (
30 |
35 | ))
36 | ) : (
37 |
38 | )}
39 |
40 |
41 | );
42 | };
43 |
44 | export default TweetDetail;
45 |
46 | export const getServerSideProps: GetServerSideProps<{}, {}> = async (ctx) => {
47 | const apolloClient = initializeApollo();
48 | const { type } = ctx.query;
49 | const jwt = getJWTFromBrowser(ctx.req, ctx.res);
50 |
51 | await apolloClient.query({
52 | query: GET_TWEET_DETAIL,
53 | variables: { tweetId: type?.length ? type[0] : '' },
54 | context: {
55 | headers: { cookie: `jwt=${jwt}` },
56 | },
57 | });
58 |
59 | await apolloClient.query({
60 | query: GET_CHILD_TWEETLIST,
61 | variables: { tweetId: type?.length ? type[0] : '' },
62 | context: {
63 | headers: { cookie: `jwt=${jwt}` },
64 | },
65 | });
66 | const initialState = apolloClient.cache.extract();
67 |
68 | return {
69 | props: {
70 | initialState,
71 | },
72 | };
73 | };
74 |
--------------------------------------------------------------------------------
/fe/src/pages/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { Box } from '@material-ui/core';
3 |
4 | const HomeBox = styled(Box)`
5 | padding: 5px;
6 | border-bottom: 1px solid rgb(235, 238, 240);
7 | font-weight: 800px;
8 | `;
9 |
10 | export default HomeBox;
11 |
--------------------------------------------------------------------------------
/fe/src/styles/global.css:
--------------------------------------------------------------------------------
1 | html,
2 | body {
3 | padding: 0;
4 | margin: 0;
5 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell,
6 | Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
7 | line-height: 1.6;
8 | font-size: 18px;
9 | }
10 |
11 | * {
12 | box-sizing: border-box;
13 | }
14 |
15 | a {
16 | color: black;
17 | text-decoration: none;
18 | cursor: pointer;
19 | }
20 |
21 | a:hover {
22 | text-decoration: none;
23 | }
24 |
25 | img {
26 | max-width: 100%;
27 | display: block;
28 | }
29 |
30 | button {
31 | cursor: pointer;
32 | }
33 |
--------------------------------------------------------------------------------
/fe/src/types/index.ts:
--------------------------------------------------------------------------------
1 | interface QueryVariableType {
2 | variables: VariableType;
3 | }
4 |
5 | interface VariableType {
6 | userId?: string;
7 | tweetId?: string;
8 | searchWord?: string;
9 | }
10 |
11 | interface UserType {
12 | _id: string;
13 | user_id: string;
14 | name: string;
15 | profile_img_url: string;
16 | comment?: string;
17 | heart_tweet_id_list: [string];
18 | following_id_list: [string];
19 | following_user?: UserType;
20 | lastest_notification_id?: string;
21 | }
22 |
23 | interface TweetType {
24 | _id: string;
25 | createAt: string;
26 | content: string;
27 | child_tweet_number: number;
28 | retweet_user_number: number;
29 | heart_user_number: number;
30 | img_url_list: [string];
31 | author: UserType;
32 | retweet_id: string;
33 | retweet: TweetType;
34 | parent_id: string;
35 | }
36 |
37 | interface NotificationType {
38 | _id: string;
39 | createAt: string;
40 | giver: UserType;
41 | tweet: TweetType;
42 | type: string;
43 | }
44 |
45 | export type { QueryVariableType, VariableType, UserType, TweetType, NotificationType };
46 |
--------------------------------------------------------------------------------