├── README.md
├── client
├── .eslintrc.js
├── .gitignore
├── .prettierrc
├── babel.config.js
├── package-lock.json
├── package.json
├── src
│ ├── App.tsx
│ ├── api
│ │ └── index.ts
│ ├── components
│ │ ├── ChannelListBox
│ │ │ ├── ChannelList
│ │ │ │ ├── ChannelItem
│ │ │ │ │ └── ChannelItem.tsx
│ │ │ │ └── ChannelList.tsx
│ │ │ ├── ChannelListBox.tsx
│ │ │ └── ChannelListHeader
│ │ │ │ ├── ChannelListHeader.tsx
│ │ │ │ ├── CreateChannelModal
│ │ │ │ ├── CreateChannelModalBody
│ │ │ │ │ └── CreateChannelModalBody.tsx
│ │ │ │ ├── CreateChannelModalHeader
│ │ │ │ │ └── CreateChannelModalHeader.tsx
│ │ │ │ └── index.ts
│ │ │ │ └── FindChannelModal
│ │ │ │ ├── FindChannelModalBody
│ │ │ │ └── FindChannelModalBody.tsx
│ │ │ │ ├── FindChannelModalHeader
│ │ │ │ └── FindChannelModalHeader.tsx
│ │ │ │ └── index.ts
│ │ ├── CodeVerifyBox
│ │ │ └── CodeVerifyBox.tsx
│ │ ├── DetailBox
│ │ │ ├── DeatailHeader
│ │ │ │ └── DetailHeader.tsx
│ │ │ └── DetailBody
│ │ │ │ ├── DetailBody.tsx
│ │ │ │ ├── DetailButtonBox
│ │ │ │ └── DetailButtonBox.tsx
│ │ │ │ └── DetailList
│ │ │ │ └── DetailList.tsx
│ │ ├── EmailBox
│ │ │ └── EmailBox.tsx
│ │ ├── LeftSideBar
│ │ │ ├── LeftSideBarContent
│ │ │ │ └── LeftSideBarContent.tsx
│ │ │ ├── LeftSideBarHeader
│ │ │ │ └── LeftSideBarHeader.tsx
│ │ │ └── LeftSidebar.tsx
│ │ ├── LoginBox
│ │ │ └── LoginBox.tsx
│ │ ├── SignupBox
│ │ │ └── SignupBox.tsx
│ │ ├── SubThreadListBox
│ │ │ ├── ParentThread
│ │ │ │ └── ParentThread.tsx
│ │ │ ├── ReplyCountHorizon
│ │ │ │ └── ReplyCountHorizon.tsx
│ │ │ ├── SubThreadList
│ │ │ │ └── SubThreadList.tsx
│ │ │ ├── SubThreadListBox.tsx
│ │ │ └── SubThreadListHeader
│ │ │ │ └── SubThreadListHeader.tsx
│ │ ├── ThreadListBox
│ │ │ ├── ThreadList
│ │ │ │ └── ThreadList.tsx
│ │ │ ├── ThreadListBox.tsx
│ │ │ └── ThreadListHeader
│ │ │ │ ├── ChannelModal
│ │ │ │ ├── AddTopicModal
│ │ │ │ │ ├── AddTopicModalBody
│ │ │ │ │ │ └── AddTopicModalBody.tsx
│ │ │ │ │ ├── AddTopicModalHeader
│ │ │ │ │ │ └── AddTopicModalHeader.tsx
│ │ │ │ │ └── index.ts
│ │ │ │ └── ShowUsersModal
│ │ │ │ │ ├── ShowUsersModalBody
│ │ │ │ │ └── ShowUsersModalBody.tsx
│ │ │ │ │ ├── ShowUsersModalHeader
│ │ │ │ │ └── ShowUsersModalHeader.tsx
│ │ │ │ │ └── index.ts
│ │ │ │ └── ThreadListHeader.tsx
│ │ ├── common
│ │ │ ├── AddUsersModal
│ │ │ │ ├── AddUsersModalBody
│ │ │ │ │ └── AddUsersModalBody.tsx
│ │ │ │ ├── AddUsersModalHeader
│ │ │ │ │ └── AddUsersModalHeader.tsx
│ │ │ │ └── index.ts
│ │ │ ├── CloseIconBox
│ │ │ │ └── CloseIconBox.tsx
│ │ │ ├── DimModal
│ │ │ │ └── DimModal.tsx
│ │ │ ├── EmojiListModal
│ │ │ │ └── EmojiListModal.tsx
│ │ │ ├── Header
│ │ │ │ ├── Header.tsx
│ │ │ │ └── UserProfileBox
│ │ │ │ │ ├── UserProfileBody
│ │ │ │ │ └── UserProfileBody.tsx
│ │ │ │ │ ├── UserProfileHeader
│ │ │ │ │ └── UserProfileHeader.tsx
│ │ │ │ │ └── index.ts
│ │ │ ├── Icon
│ │ │ │ ├── AddUserIcon
│ │ │ │ │ └── AddUserIcon.tsx
│ │ │ │ ├── ArrowDownIcon
│ │ │ │ │ └── ArrowDownIcon.tsx
│ │ │ │ ├── ArrowRightIcon
│ │ │ │ │ └── ArrowRightIcon.tsx
│ │ │ │ ├── ChannelLockIcon
│ │ │ │ │ └── ChannelLockIcon.tsx
│ │ │ │ ├── ClockIcon
│ │ │ │ │ └── ClockIcon.tsx
│ │ │ │ ├── CommentIcon
│ │ │ │ │ └── CommentIcon.tsx
│ │ │ │ ├── ConversationIcon
│ │ │ │ │ └── ConversationIcon.tsx
│ │ │ │ ├── DotIcon
│ │ │ │ │ └── DotIcon.tsx
│ │ │ │ ├── GoogleLogoIcon
│ │ │ │ │ └── GoogleLogoIcon.tsx
│ │ │ │ ├── LightIcon
│ │ │ │ │ └── LightIcon.tsx
│ │ │ │ ├── LockIcon
│ │ │ │ │ └── LockIcon.tsx
│ │ │ │ ├── PaperPlaneIcon
│ │ │ │ │ └── PaperPlaneIcon.tsx
│ │ │ │ ├── PlusIcon
│ │ │ │ │ └── PlusIcon.tsx
│ │ │ │ ├── PoundIcon
│ │ │ │ │ └── PoundIcon.tsx
│ │ │ │ ├── ReactionIcon
│ │ │ │ │ └── ReactionIcon.tsx
│ │ │ │ ├── RightArrowLineIcon
│ │ │ │ │ └── RightArrowLineIcon.tsx
│ │ │ │ ├── RightIcon
│ │ │ │ │ └── RightIcon.tsx
│ │ │ │ ├── TrashIcon
│ │ │ │ │ └── TrashIcon.tsx
│ │ │ │ ├── UserStateIcon
│ │ │ │ │ └── UserStateIcon.tsx
│ │ │ │ ├── WarningIcon
│ │ │ │ │ └── WarningIcon.tsx
│ │ │ │ ├── WriteIcon
│ │ │ │ │ └── WriteIcon.tsx
│ │ │ │ └── index.ts
│ │ │ ├── LazyImage
│ │ │ │ └── LazyImage.tsx
│ │ │ ├── LogoBox
│ │ │ │ └── LogoBox.tsx
│ │ │ ├── ModalCloseBox
│ │ │ │ └── ModalCloseBox.tsx
│ │ │ ├── Popover
│ │ │ │ └── Popover.tsx
│ │ │ ├── RightSideBar
│ │ │ │ ├── RightSideBar.tsx
│ │ │ │ ├── RightSideBarBody
│ │ │ │ │ └── RightSideBarBody.tsx
│ │ │ │ └── RightSideBarHeader
│ │ │ │ │ └── RightSideBarHeader.tsx
│ │ │ ├── ThreadInputBox
│ │ │ │ └── ThreadInputBox.tsx
│ │ │ ├── ThreadItem
│ │ │ │ ├── EmojiBox
│ │ │ │ │ ├── EmojiBox.tsx
│ │ │ │ │ └── EmojiBoxItem
│ │ │ │ │ │ ├── EmojiBoxItem.tsx
│ │ │ │ │ │ └── TooltipPopup
│ │ │ │ │ │ └── TooltipPopup.tsx
│ │ │ │ ├── ReplyButton
│ │ │ │ │ └── ReplyButton.tsx
│ │ │ │ ├── ThreadItem.tsx
│ │ │ │ └── ThreadPopup
│ │ │ │ │ └── ThreadPopup.tsx
│ │ │ └── index.ts
│ │ └── index.ts
│ ├── config
│ │ └── index.ts
│ ├── fonts
│ │ ├── NotoSansKR-Bold.otf
│ │ ├── NotoSansKR-Bold.woff
│ │ ├── NotoSansKR-Bold.woff2
│ │ ├── NotoSansKR-Light.otf
│ │ ├── NotoSansKR-Light.woff
│ │ ├── NotoSansKR-Light.woff2
│ │ ├── NotoSansKR-Medium.otf
│ │ ├── NotoSansKR-Medium.woff
│ │ ├── NotoSansKR-Medium.woff2
│ │ ├── NotoSansKR-Regular.otf
│ │ ├── NotoSansKR-Regular.woff
│ │ └── NotoSansKR-Regular.woff2
│ ├── hoc
│ │ ├── RestrictRoute.tsx
│ │ ├── index.ts
│ │ └── withAuth.tsx
│ ├── hooks
│ │ ├── index.ts
│ │ ├── useAuthState.ts
│ │ ├── useChannelState.ts
│ │ ├── useDuplicatedChannelState.ts
│ │ ├── useEmojiState.ts
│ │ ├── useFindChannel.ts
│ │ ├── useInfiniteScroll.ts
│ │ ├── useOnClickOutside.ts
│ │ ├── useRedirectState.ts
│ │ ├── useSignupState.ts
│ │ ├── useSocketState.ts
│ │ ├── useSubThreadState.ts
│ │ ├── useThreadState.ts
│ │ ├── useUserState.ts
│ │ └── useViewport.ts
│ ├── index.html
│ ├── index.tsx
│ ├── pages
│ │ ├── EmailVerifyPage.tsx
│ │ ├── HomePage.tsx
│ │ ├── LoadingPage.tsx
│ │ ├── LoginPage.tsx
│ │ ├── NotFoundPage.tsx
│ │ ├── SignupPage.tsx
│ │ ├── WorkSpacePage.tsx
│ │ └── index.ts
│ ├── public
│ │ └── icon
│ │ │ ├── loading-color.svg
│ │ │ ├── loading.svg
│ │ │ ├── slack-logo.gif
│ │ │ ├── slack-logo.svg
│ │ │ ├── slack-logo.webp
│ │ │ └── slack.ico
│ ├── services
│ │ ├── auth.service.ts
│ │ ├── channel.service.ts
│ │ ├── emoji.service.ts
│ │ ├── index.ts
│ │ ├── subThread.service.ts
│ │ ├── thread.service.ts
│ │ └── user.service.ts
│ ├── store
│ │ ├── index.ts
│ │ ├── modules
│ │ │ ├── auth.slice.ts
│ │ │ ├── channel.slice.ts
│ │ │ ├── duplicatedChannel.slice.ts
│ │ │ ├── emoji.slice.ts
│ │ │ ├── findChannel.slice.ts
│ │ │ ├── index.ts
│ │ │ ├── redirect.slice.ts
│ │ │ ├── signup.slice.ts
│ │ │ ├── socket.slice.ts
│ │ │ ├── subThread.slice.ts
│ │ │ ├── thread.slice.ts
│ │ │ └── user.slice.ts
│ │ └── sagas
│ │ │ ├── authSaga.ts
│ │ │ ├── channelSaga.ts
│ │ │ ├── duplicatedChannelSaga.ts
│ │ │ ├── emojiSaga.ts
│ │ │ ├── findChannelSaga.ts
│ │ │ ├── index.ts
│ │ │ ├── signupSaga.ts
│ │ │ ├── socketSaga.ts
│ │ │ ├── subThreadSaga.ts
│ │ │ ├── threadSaga.ts
│ │ │ └── userSaga.ts
│ ├── styles
│ │ ├── index.ts
│ │ ├── mixin.ts
│ │ ├── shared
│ │ │ ├── button.ts
│ │ │ ├── form.ts
│ │ │ └── index.ts
│ │ ├── styled.d.ts
│ │ └── theme.ts
│ ├── types
│ │ ├── asset.d.ts
│ │ ├── auth.ts
│ │ ├── channel.ts
│ │ ├── channelInfo.ts
│ │ ├── index.ts
│ │ ├── service.ts
│ │ ├── socket.ts
│ │ ├── thread.ts
│ │ └── user.ts
│ └── utils
│ │ ├── constants.ts
│ │ └── utils.ts
├── tsconfig.json
└── webpack.config.js
├── deploy.sh
└── server
├── .eslintrc.js
├── .gitignore
├── .prettierrc
├── package-lock.json
├── package.json
├── src
├── app.ts
├── config
│ └── index.ts
├── db
│ └── index.ts
├── lib
│ ├── passport.ts
│ └── socket.ts
├── middlewares
│ └── auth.middleware.ts
├── models
│ ├── channels.model.ts
│ ├── emoji.model.ts
│ ├── index.ts
│ ├── thread.model.ts
│ └── user.model.ts
├── public
│ └── imgs
│ │ ├── etc
│ │ └── .keep
│ │ └── profile
│ │ └── .keep
├── routes
│ ├── api
│ │ ├── auth
│ │ │ ├── auth.controller.ts
│ │ │ ├── auth.yaml
│ │ │ └── index.ts
│ │ ├── channels
│ │ │ ├── [channelId]
│ │ │ │ ├── channelId.controller.ts
│ │ │ │ ├── channelsId.yaml
│ │ │ │ └── index.ts
│ │ │ ├── channels.controller.ts
│ │ │ ├── channels.yaml
│ │ │ └── index.ts
│ │ ├── emojis
│ │ │ ├── emojis.controllers.ts
│ │ │ └── index.ts
│ │ ├── index.ts
│ │ ├── oauth
│ │ │ ├── index.ts
│ │ │ └── oauth.controller.ts
│ │ ├── threads
│ │ │ ├── [threadId]
│ │ │ │ ├── index.ts
│ │ │ │ ├── threadId.controller.ts
│ │ │ │ └── threadId.yaml
│ │ │ ├── index.ts
│ │ │ ├── threads.controller.ts
│ │ │ └── threads.yaml
│ │ └── users
│ │ │ ├── [userId]
│ │ │ ├── index.ts
│ │ │ ├── userId.controller.ts
│ │ │ └── userId.yaml
│ │ │ ├── index.ts
│ │ │ ├── users.controller.ts
│ │ │ └── users.yaml
│ └── swagger.yaml
├── services
│ ├── channel.service.ts
│ ├── emoji.service.ts
│ ├── index.ts
│ └── thread.service.ts
├── types
│ └── index.ts
└── utils
│ ├── constants.ts
│ └── utils.ts
└── tsconfig.json
/README.md:
--------------------------------------------------------------------------------
1 | # 팀 협업도구, 우리동네 슬랙 🚀
2 |
3 |
4 |
5 | > 배포 주소 : ~https://boost-slack.ga/~ (서버 반납)
6 |
7 |
8 |
9 | ## 🎞 데모 영상
10 |
11 |
12 |
13 | 클릭 시 영상 링크로 이동합니다!
14 |
15 |
16 |
17 | ## 🔎 프로젝트 소개
18 |
19 | 팀 협업도구로 유명한 슬랙을 구현하는 프로젝트 입니다.
20 | 기본적인 회원가입 및 로그인, 채널 분리, 실시간 대화, 이모지 등의 기능을 구현하였습니다.
21 |
22 |
23 |
24 | ## 🧱 기술 스택
25 |
26 |
27 |
28 |
29 |
30 | ## 📔 Wiki
31 | 프로젝트와 관련된 상세한 내용은 [Wiki](https://github.com/boostcamp-2020/Project06-A-Slack/wiki) 그리고 초대된 노션에 기록되어 있습니다!
32 |
33 |
34 |
35 | ## 💁🏻♀️💁🏻♂️ 멤버
36 |
37 | | 멘토님 | J014 | J020 | J214 |
38 | |:-------:| :-----------------------------------------------:| :---------------------------------------------: | :----------------------------------------------------: |
39 | |(추가예정)|
|
|
|
40 | | 오혜성| 권순원[(grap3fruit)](https://github.com/grap3fruit)| 권현준[(rnjshippo)](https://github.com/rnjshippo)| 한상욱[(hansanguk0222)](https://github.com/hansanguk0222)|
41 |
--------------------------------------------------------------------------------
/client/.eslintrc.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | module.exports = {
4 | env: {
5 | browser: true,
6 | es2021: true,
7 | node: true,
8 | },
9 | extends: [
10 | 'plugin:react/recommended',
11 | 'airbnb',
12 | 'plugin:@typescript-eslint/recommended',
13 | 'prettier/@typescript-eslint',
14 | 'plugin:prettier/recommended',
15 | ],
16 | parser: '@typescript-eslint/parser',
17 | parserOptions: {
18 | ecmaFeatures: {
19 | jsx: true,
20 | },
21 | ecmaVersion: 12,
22 | sourceType: 'module',
23 | },
24 | plugins: ['react', '@typescript-eslint', 'prettier'],
25 | rules: {
26 | 'react/jsx-filename-extension': [1, { extensions: ['.js', '.jsx', '.ts', '.tsx'] }],
27 | 'react/jsx-one-expression-per-line': 'off',
28 | 'prettier/prettier': 'error',
29 | 'no-console': 'warn',
30 | 'no-plusplus': ['error', { allowForLoopAfterthoughts: true }],
31 | 'no-unused-expressions': ['warn'],
32 | 'import/extensions': [
33 | 'error',
34 | 'always',
35 | {
36 | js: 'never',
37 | jsx: 'never',
38 | ts: 'never',
39 | tsx: 'never',
40 | },
41 | ],
42 | 'no-constant-condition': ['error', { checkLoops: false }],
43 | 'no-restricted-globals': 'warn',
44 | 'no-use-before-define': ['off'], // import React할 때 에러떠서 off
45 | '@typescript-eslint/no-use-before-define': ['warn'],
46 | 'import/prefer-default-export': 'off', // 한 개만 export할때는 export default를 쓰도록 하는 옵션
47 | },
48 | settings: {
49 | 'import/resolver': {
50 | typescript: {
51 | project: path.join(__dirname, './tsconfig.json'), // tsconfig 옵션을 감지하도록 추가
52 | },
53 | },
54 | react: { version: 'detect' },
55 | },
56 | ignorePatterns: ['node_modules', 'babel.config.js', 'webpack.config.js', '.eslintrc.js'],
57 | };
58 |
--------------------------------------------------------------------------------
/client/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .env*
3 | dist
--------------------------------------------------------------------------------
/client/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 100,
3 | "tabWidth": 2,
4 | "singleQuote": true,
5 | "trailingComma": "all",
6 | "bracketSpacing": true,
7 | "semi": true,
8 | "useTabs": false,
9 | "arrowParens": "always",
10 | "endOfLine": "auto"
11 | }
12 |
--------------------------------------------------------------------------------
/client/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = (api) => {
2 | api.cache(true);
3 |
4 | const presets = [
5 | [
6 | '@babel/preset-env',
7 | {
8 | targets: '> 0.25%, not dead',
9 | useBuiltIns: 'usage',
10 | corejs: '3',
11 | modules: false,
12 | },
13 | ],
14 | '@babel/preset-react',
15 | '@babel/preset-typescript',
16 | ];
17 | const plugins = [
18 | ['@babel/plugin-transform-async-to-generator'],
19 | ['@babel/plugin-transform-runtime', { corejs: 3 }],
20 | ['@babel/plugin-transform-arrow-functions'],
21 | ];
22 |
23 | return {
24 | plugins,
25 | presets,
26 | };
27 | };
28 |
--------------------------------------------------------------------------------
/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "slace-client",
3 | "version": "0.0.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "cross-env MODE=dev webpack-dev-server --open --mode development --progress",
7 | "watch": "webpack --mode development --progress",
8 | "build": "webpack --mode production --progress"
9 | },
10 | "dependencies": {
11 | "@reduxjs/toolkit": "^1.4.0",
12 | "axios": "^0.21.0",
13 | "core-js": "^3.7.0",
14 | "jsonwebtoken": "^8.5.1",
15 | "polished": "^4.0.5",
16 | "qs": "^6.9.4",
17 | "react": "^17.0.1",
18 | "react-dom": "^17.0.1",
19 | "react-google-login": "^5.1.25",
20 | "react-redux": "^7.2.2",
21 | "react-router-dom": "^5.2.0",
22 | "redux": "^4.0.5",
23 | "redux-saga": "^1.1.3",
24 | "socket.io-client": "^3.0.2",
25 | "styled-components": "^5.2.1",
26 | "styled-normalize": "^8.0.7",
27 | "validator": "^13.1.17"
28 | },
29 | "devDependencies": {
30 | "@babel/cli": "^7.12.1",
31 | "@babel/core": "^7.12.3",
32 | "@babel/plugin-transform-arrow-functions": "^7.12.1",
33 | "@babel/plugin-transform-async-to-generator": "^7.12.1",
34 | "@babel/plugin-transform-runtime": "^7.12.1",
35 | "@babel/preset-env": "^7.12.1",
36 | "@babel/preset-react": "^7.12.5",
37 | "@babel/preset-typescript": "^7.12.1",
38 | "@babel/runtime": "^7.12.5",
39 | "@babel/runtime-corejs3": "^7.11.2",
40 | "@types/jsonwebtoken": "^8.5.0",
41 | "@types/node": "^14.14.8",
42 | "@types/qs": "^6.9.5",
43 | "@types/react": "^16.9.56",
44 | "@types/react-dom": "^16.9.9",
45 | "@types/react-redux": "^7.1.11",
46 | "@types/react-router-dom": "^5.1.6",
47 | "@types/socket.io-client": "^1.4.34",
48 | "@types/styled-components": "^5.1.4",
49 | "@types/validator": "^13.1.0",
50 | "@typescript-eslint/eslint-plugin": "^4.8.1",
51 | "@typescript-eslint/parser": "^4.8.1",
52 | "babel-loader": "^8.2.1",
53 | "clean-webpack-plugin": "^3.0.0",
54 | "cross-env": "^7.0.2",
55 | "css-loader": "^5.0.0",
56 | "dotenv-webpack": "^5.1.0",
57 | "eslint": "^7.13.0",
58 | "eslint-config-airbnb": "^18.2.1",
59 | "eslint-config-prettier": "^6.13.0",
60 | "eslint-config-react-app": "^6.0.0",
61 | "eslint-import-resolver-alias": "^1.1.2",
62 | "eslint-import-resolver-typescript": "^2.3.0",
63 | "eslint-plugin-import": "^2.22.1",
64 | "eslint-plugin-jsx-a11y": "^6.4.1",
65 | "eslint-plugin-prettier": "^3.1.4",
66 | "eslint-plugin-react": "^7.21.5",
67 | "eslint-plugin-react-hooks": "^4.2.0",
68 | "file-loader": "^6.1.1",
69 | "html-webpack-plugin": "^4.5.0",
70 | "prettier": "^2.1.2",
71 | "sass": "^1.27.0",
72 | "sass-loader": "^10.0.4",
73 | "style-loader": "^2.0.0",
74 | "terser-webpack-plugin": "^4.2.3",
75 | "ts-loader": "^8.0.11",
76 | "typescript": "^4.0.5",
77 | "url-loader": "^4.1.1",
78 | "webpack": "^4.44.2",
79 | "webpack-cli": "^3.3.12",
80 | "webpack-dev-server": "^3.11.0"
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/client/src/App.tsx:
--------------------------------------------------------------------------------
1 | import React, { lazy, Suspense } from 'react';
2 | import { BrowserRouter, Redirect, Route, Switch } from 'react-router-dom';
3 | import { ThemeProvider } from 'styled-components';
4 | import LoadingPage from '@/pages/LoadingPage';
5 | import theme from '@/styles/theme';
6 | import { GlobalStyle } from '@/styles';
7 | import { withAuth } from '@/hoc';
8 |
9 | const HomePage = lazy(() => import('@/pages/HomePage'));
10 | const LoginPage = lazy(() => import('@/pages/LoginPage'));
11 | const EmailVerifyPage = lazy(() => import('@/pages/EmailVerifyPage'));
12 | const SignupPage = lazy(() => import('@/pages/SignupPage'));
13 | const WorkSpacePage = lazy(() => import('@/pages/WorkSpacePage'));
14 |
15 | const App = () => {
16 | return (
17 |
18 |
19 |
20 | }>
21 |
22 |
23 |
24 |
25 |
26 |
31 |
32 |
33 |
34 |
35 |
36 | );
37 | };
38 |
39 | export default App;
40 |
--------------------------------------------------------------------------------
/client/src/api/index.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { TOKEN_TYPE } from '@/utils/constants';
3 | import { verifyJWT } from '@/utils/utils';
4 |
5 | const instance = axios.create({ timeout: 9000 });
6 |
7 | instance.interceptors.request.use(
8 | async (config) => {
9 | const accessToken = localStorage.getItem('accessToken');
10 | if (accessToken) {
11 | try {
12 | await verifyJWT(accessToken, TOKEN_TYPE.ACCESS);
13 | return {
14 | ...config,
15 | headers: { ...config.headers, Authorization: `Bearer ${accessToken}` },
16 | };
17 | } catch (err) {
18 | const refreshToken = localStorage.getItem('refreshToken');
19 | if (refreshToken) {
20 | try {
21 | const { data, status } = await axios.post('/api/auth/token/refresh', {
22 | refreshToken,
23 | });
24 | if (status === 200) {
25 | localStorage.setItem('accessToken', data.accessToken);
26 | return {
27 | ...config,
28 | headers: { ...config.headers, Authorization: `Bearer ${data.accessToken}` },
29 | };
30 | }
31 | } catch (error) {
32 | if (error?.response?.status === 401) {
33 | localStorage.removeItem('accessToken');
34 | localStorage.removeItem('refreshToken');
35 | localStorage.removeItem('userId');
36 | window.location.href = '/login';
37 | }
38 | }
39 | }
40 | }
41 | }
42 | return config;
43 | },
44 | (err) => {
45 | return Promise.reject(err);
46 | },
47 | );
48 |
49 | instance.interceptors.response.use(
50 | (res) => {
51 | if (res.status >= 400) {
52 | console.error('api 요청 실패', res);
53 | }
54 | return res;
55 | },
56 | (err) => {
57 | if (axios.isCancel(err)) {
58 | console.log('요청 취소', err);
59 | } else {
60 | if (err?.response?.status === 401) {
61 | const { url } = err.response.config;
62 | if (url !== '/api/auth/login' && url !== '/api/oauth/google/signup') {
63 | window.location.href = '/login';
64 | }
65 | }
66 | console.error('api 에러', err);
67 | }
68 | return Promise.reject(err);
69 | },
70 | );
71 |
72 | export default instance;
73 |
--------------------------------------------------------------------------------
/client/src/components/ChannelListBox/ChannelList/ChannelItem/ChannelItem.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import styled, { css } from 'styled-components';
3 | import { Link } from 'react-router-dom';
4 | import { useJoinChannelListState, useChannelState } from '@/hooks';
5 | import { flex } from '@/styles/mixin';
6 | import { LockIcon, PoundIcon } from '@/components';
7 | import { useDispatch } from 'react-redux';
8 | import { unsetUnreadFlag } from '@/store/modules/channel.slice';
9 |
10 | interface ChannelProps {
11 | picked: boolean;
12 | }
13 |
14 | const Channel = styled.div`
15 | ${flex('center', 'flex-center')}
16 | height: 1.75rem;
17 | padding: 1rem 0 1rem 1.75rem;
18 | font-size: 0.95rem;
19 | color: ${(props) =>
20 | props.picked ? props.theme.color.semiWhite : props.theme.color.channelItemColor};
21 | &:hover {
22 | ${(props) =>
23 | !props.picked &&
24 | css`
25 | background: rgba(0, 0, 0, 0.2);
26 | `}
27 | }
28 | background: ${(props) => (props.picked ? props.theme.color.blue1 : 'transparent')};
29 | user-select: none;
30 | `;
31 |
32 | const Icon = styled.div`
33 | margin-right: 15px;
34 | `;
35 |
36 | interface NameProps {
37 | unreadMessage: boolean;
38 | }
39 |
40 | const Name = styled.span`
41 | font-weight: 400;
42 | overflow: hidden;
43 | text-overflow: ellipsis;
44 | white-space: nowrap;
45 | font-weight: ${(props) => (props.unreadMessage ? '800' : 'normal')};
46 | color: ${(props) => (props.unreadMessage ? 'white' : 'inherit')};
47 | `;
48 |
49 | interface ChannelItemProps {
50 | idx: number;
51 | }
52 |
53 | const ChannelItem = ({ idx }: ChannelItemProps) => {
54 | const { id, name, isPublic } = useJoinChannelListState(idx);
55 | const { current, myChannelList } = useChannelState();
56 |
57 | const { unreadMessage } = myChannelList[idx];
58 |
59 | const picked = id === current?.id;
60 | const unread = !!unreadMessage && !picked;
61 |
62 | const dispatch = useDispatch();
63 |
64 | useEffect(() => {
65 | if (picked && current) {
66 | dispatch(unsetUnreadFlag({ channelId: current.id }));
67 | }
68 | }, [current]);
69 |
70 | return (
71 |
72 |
73 |
74 | {isPublic ? (
75 |
76 | ) : (
77 |
78 | )}
79 |
80 | {name}
81 |
82 |
83 | );
84 | };
85 |
86 | export default ChannelItem;
87 |
--------------------------------------------------------------------------------
/client/src/components/ChannelListBox/ChannelList/ChannelList.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-nested-ternary */
2 | /* eslint-disable react/jsx-props-no-spreading */
3 | import React, { ReactElement, useEffect } from 'react';
4 | import { useDispatch } from 'react-redux';
5 | import { loadMyChannelsRequest, setReloadMyChannelListFlag } from '@/store/modules/channel.slice';
6 | import { useChannelState, useAuthState } from '@/hooks';
7 | import { Channel } from '@/types';
8 | import ChannelItem from './ChannelItem/ChannelItem';
9 |
10 | const ChannelList = ({
11 | channelType,
12 | channelListVisible,
13 | }: {
14 | channelType: number;
15 | channelListVisible: boolean;
16 | }): ReactElement => {
17 | const dispatch = useDispatch();
18 | const { myChannelList, current, reloadMyChannelList } = useChannelState();
19 | const { userId } = useAuthState();
20 |
21 | useEffect(() => {
22 | if (userId) {
23 | dispatch(loadMyChannelsRequest({ userId: +userId }));
24 | }
25 | }, [dispatch, userId]);
26 |
27 | useEffect(() => {
28 | if (reloadMyChannelList) {
29 | if (userId) {
30 | dispatch(loadMyChannelsRequest({ userId: +userId }));
31 | }
32 | dispatch(setReloadMyChannelListFlag({ reloadMyChannelList: false }));
33 | }
34 | }, [reloadMyChannelList]);
35 |
36 | return (
37 | <>
38 | {myChannelList?.map(
39 | (channel: Channel, idx: number) =>
40 | channelType === channel.channelType &&
41 | (!channelListVisible ? (
42 | current?.id === channel.id &&
43 | ) : (
44 |
45 | )),
46 | )}
47 | >
48 | );
49 | };
50 |
51 | export default ChannelList;
52 |
--------------------------------------------------------------------------------
/client/src/components/ChannelListBox/ChannelListBox.tsx:
--------------------------------------------------------------------------------
1 | import { CHANNEL_TYPE } from '@/utils/constants';
2 | import React, { ReactElement, useState } from 'react';
3 | import styled, { css } from 'styled-components';
4 | import ChannelList from './ChannelList/ChannelList';
5 | import ChannelListHeader from './ChannelListHeader/ChannelListHeader';
6 |
7 | interface ContainerProps {
8 | isDM: boolean;
9 | }
10 |
11 | const Container = styled.div`
12 | padding: ${(props) => props.theme.size.s} 0;
13 | ${(props) =>
14 | props.isDM &&
15 | css`
16 | min-height: 200px;
17 | `}
18 | `;
19 |
20 | const ChannelListBox = ({ channelType }: { channelType: number }): ReactElement => {
21 | const [channelListVisible, setChannelListVisible] = useState(true);
22 | return (
23 |
24 |
29 |
30 |
31 | );
32 | };
33 |
34 | export default ChannelListBox;
35 |
--------------------------------------------------------------------------------
/client/src/components/ChannelListBox/ChannelListHeader/CreateChannelModal/CreateChannelModalHeader/CreateChannelModalHeader.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/no-unescaped-entities */
2 | /* eslint-disable @typescript-eslint/ban-types */
3 | import React from 'react';
4 | import styled from 'styled-components';
5 |
6 | const HeaderContent = styled.div`
7 | font-size: 1.8rem;
8 | font-weight: 800;
9 | color: ${(props) => props.theme.color.lightBlack};
10 | padding: 14px 0;
11 | `;
12 |
13 | interface CreateChannelModalHeaderProps {
14 | secret: boolean;
15 | }
16 |
17 | const CreateChannelModalHeader: React.FC = ({
18 | secret,
19 | }: CreateChannelModalHeaderProps) => {
20 | return {secret ? 'Create a private channel' : 'Create a channel'};
21 | };
22 |
23 | export default CreateChannelModalHeader;
24 |
--------------------------------------------------------------------------------
/client/src/components/ChannelListBox/ChannelListHeader/CreateChannelModal/index.ts:
--------------------------------------------------------------------------------
1 | export { default as CreateChannelModalHeader } from './CreateChannelModalHeader/CreateChannelModalHeader';
2 | export { default as CreateChannelModalBody } from './CreateChannelModalBody/CreateChannelModalBody';
3 |
--------------------------------------------------------------------------------
/client/src/components/ChannelListBox/ChannelListHeader/FindChannelModal/FindChannelModalHeader/FindChannelModalHeader.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { flex } from '@/styles/mixin';
4 |
5 | const Container = styled.div`
6 | ${flex('center', 'flex-start')}
7 | width: 100%;
8 | height: 3.2rem;
9 | flex-shrink: 0;
10 | font-size: 1.4rem;
11 | font-weight: 800;
12 | background-color: white;
13 | color: ${(props) => props.theme.color.lightBlack};
14 | `;
15 |
16 | const FindChannelModalHeader: React.FC = () => {
17 | return Channel browser;
18 | };
19 |
20 | export default FindChannelModalHeader;
21 |
--------------------------------------------------------------------------------
/client/src/components/ChannelListBox/ChannelListHeader/FindChannelModal/index.ts:
--------------------------------------------------------------------------------
1 | export { default as FindChannelModalHeader } from './FindChannelModalHeader/FindChannelModalHeader';
2 | export { default as FindChannelModalBody } from './FindChannelModalBody/FindChannelModalBody';
3 |
--------------------------------------------------------------------------------
/client/src/components/DetailBox/DeatailHeader/DetailHeader.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { useChannelState } from '@/hooks';
4 | import { flex } from '@/styles/mixin';
5 | import { LockIcon, PoundIcon } from '@/components';
6 | import theme from '@/styles/theme';
7 |
8 | const Container = styled.div`
9 | ${flex('center', 'space-between')}
10 | width: 100%;
11 | height: 4.3rem;
12 | flex-shrink: 0;
13 | background-color: white;
14 | `;
15 |
16 | const LeftBox = styled.div``;
17 |
18 | const LeftTopBox = styled.div`
19 | font-weight: 800;
20 | `;
21 |
22 | const LeftBottomBox = styled.div`
23 | width: 16rem;
24 | overflow: hidden;
25 | text-overflow: ellipsis;
26 | white-space: nowrap;
27 | `;
28 |
29 | const ChannelTitle = styled.span`
30 | color: ${(props) => props.theme.color.black5};
31 | font-size: 0.8rem;
32 | font-weight: 200;
33 | margin-left: 0.2rem;
34 | `;
35 |
36 | const DetailHeader: React.FC = () => {
37 | const { current } = useChannelState();
38 |
39 | return (
40 |
41 |
42 | Detail
43 |
44 | {current?.isPublic ? (
45 |
46 | ) : (
47 |
48 | )}
49 | {current?.name}
50 |
51 |
52 |
53 | );
54 | };
55 |
56 | export default DetailHeader;
57 |
--------------------------------------------------------------------------------
/client/src/components/DetailBox/DetailBody/DetailBody.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import React from 'react';
3 | import { DetailList } from './DetailList/DetailList';
4 | import { DetailButtonBox } from './DetailButtonBox/DetailButtonBox';
5 |
6 | const Container = styled.div``;
7 |
8 | const DetailBody: React.FC = () => {
9 | return (
10 |
11 |
12 |
13 |
14 | );
15 | };
16 |
17 | export default DetailBody;
18 |
--------------------------------------------------------------------------------
/client/src/components/DetailBox/DetailBody/DetailButtonBox/DetailButtonBox.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { flex } from '@/styles/mixin';
4 |
5 | const Container = styled.div`
6 | ${flex('center', 'space-around')}
7 | padding: 1.5rem 2rem;
8 | border-bottom: 1px solid ${(props) => props.theme.color.lightGray2};
9 | `;
10 |
11 | const Label = styled.label`
12 | ${flex('center', 'center', 'column')}
13 | color: ${(props) => props.theme.color.black8};
14 | font-size: 0.8rem;
15 | font-weight: 200;
16 | `;
17 |
18 | const Button = styled.button`
19 | ${flex()}
20 | border-radius: 50%;
21 | width: 40px;
22 | height: 40px;
23 | border: none;
24 | outline: none;
25 | background-color: #f3f3f3;
26 | &:hover {
27 | background-color: ${(props) => props.theme.color.lightGray2};
28 | }
29 | &:active {
30 | background-color: ${(props) => props.theme.color.lightGray1};
31 | }
32 | `;
33 |
34 | export const DetailButtonBox: React.FC = () => {
35 | return (
36 |
37 |
41 |
45 |
49 |
53 |
54 | );
55 | };
56 |
--------------------------------------------------------------------------------
/client/src/components/LeftSideBar/LeftSideBarContent/LeftSideBarContent.tsx:
--------------------------------------------------------------------------------
1 | import React, { ReactElement } from 'react';
2 | import styled from 'styled-components';
3 | import ChannelListBox from '@/components/ChannelListBox/ChannelListBox';
4 | import { CHANNEL_TYPE } from '@/utils/constants';
5 |
6 | const Container = styled.div`
7 | overflow-x: hidden;
8 | overflow-y: auto;
9 | flex: 1;
10 | `;
11 |
12 | const LeftSideBarContent = (): ReactElement => {
13 | return (
14 |
15 |
16 |
17 |
18 | );
19 | };
20 |
21 | export default LeftSideBarContent;
22 |
--------------------------------------------------------------------------------
/client/src/components/LeftSideBar/LeftSideBarHeader/LeftSideBarHeader.tsx:
--------------------------------------------------------------------------------
1 | import React, { ReactElement } from 'react';
2 | import styled from 'styled-components';
3 | import { flex } from '@/styles/mixin';
4 | import { WriteIcon } from '@/components';
5 |
6 | const Container = styled.div`
7 | width: 16.25rem;
8 | height: 4.3rem;
9 | padding: 15px;
10 | background: ${(props) => props.theme.color.purple2};
11 | ${flex('center', 'space-between')}
12 | border-top: 1px solid ${(props) => props.theme.color.channelBorder};
13 | border-bottom: 1px solid ${(props) => props.theme.color.channelBorder};
14 | `;
15 |
16 | const Title = styled.div`
17 | color: ${(props) => props.theme.color.white};
18 | font-weight: bold;
19 | `;
20 |
21 | const Button = styled.button`
22 | width: 2.25rem;
23 | height: 2.25rem;
24 | border-radius: 50%;
25 | background: ${(props) => props.theme.color.white};
26 | border: none;
27 | padding-top: 0.1rem;
28 | ${flex()};
29 | `;
30 |
31 | const workspace = '부스트 캠프 2020 멤버십';
32 |
33 | const LeftSideBar = (): ReactElement => {
34 | return (
35 |
36 | {workspace}
37 |
40 |
41 | );
42 | };
43 |
44 | export default LeftSideBar;
45 |
--------------------------------------------------------------------------------
/client/src/components/LeftSideBar/LeftSidebar.tsx:
--------------------------------------------------------------------------------
1 | import React, { ReactElement } from 'react';
2 | import styled from 'styled-components';
3 | import LeftSidebarHeader from './LeftSideBarHeader/LeftSideBarHeader';
4 | import LeftSidebarContent from './LeftSideBarContent/LeftSideBarContent';
5 |
6 | const Container = styled.div`
7 | width: 16.25rem;
8 | height: 100%;
9 | display: flex;
10 | flex-direction: column;
11 | background: ${(props) => props.theme.color.purple2};
12 | `;
13 |
14 | const LeftSideBar = (): ReactElement => {
15 | return (
16 |
17 |
18 |
19 |
20 | );
21 | };
22 |
23 | export default LeftSideBar;
24 |
--------------------------------------------------------------------------------
/client/src/components/SubThreadListBox/ParentThread/ParentThread.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { Thread } from '@/types';
4 | import { ThreadItem } from '@/components';
5 |
6 | const Container = styled.div``;
7 |
8 | interface parentThreadProps {
9 | parentThread: Thread;
10 | }
11 |
12 | const SubThreadList: React.FC = ({ parentThread }: parentThreadProps) => {
13 | if (parentThread.isDeleted) {
14 | if (parentThread.subCount > 0) {
15 | const deletedThread = { ...parentThread, content: 'This message was deleted.' };
16 | return (
17 |
18 |
19 |
20 | );
21 | }
22 | }
23 |
24 | return (
25 |
26 |
27 |
28 | );
29 | };
30 |
31 | export default SubThreadList;
32 |
--------------------------------------------------------------------------------
/client/src/components/SubThreadListBox/ReplyCountHorizon/ReplyCountHorizon.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { flex } from '@/styles/mixin';
4 |
5 | const Container = styled.div`
6 | width: 100%;
7 | ${flex()};
8 | padding: 0.5rem 1rem;
9 | `;
10 |
11 | const Text = styled.div`
12 | color: ${(props) => props.theme.color.black8};
13 | margin-right: 0.5rem;
14 | font-size: 0.75rem;
15 | font-weight: 400;
16 | white-space: nowrap;
17 | `;
18 |
19 | const Line = styled.div`
20 | width: 100%;
21 | height: 0.5px;
22 | background-color: ${(props) => props.theme.color.lightGray2};
23 | `;
24 |
25 | interface ReplyCountHorizonProps {
26 | subCount: number;
27 | }
28 |
29 | const ReplyCountHorizon: React.FC = ({
30 | subCount,
31 | }: ReplyCountHorizonProps) => {
32 | return (
33 |
34 | {subCount === 1 ? `${subCount} reply` : `${subCount} replies`}
35 |
36 |
37 | );
38 | };
39 |
40 | export default ReplyCountHorizon;
41 |
--------------------------------------------------------------------------------
/client/src/components/SubThreadListBox/SubThreadList/SubThreadList.tsx:
--------------------------------------------------------------------------------
1 | import React, { useRef } from 'react';
2 | import styled from 'styled-components';
3 | import { Thread } from '@/types';
4 | import { ThreadItem } from '@/components';
5 |
6 | const Container = styled.div``;
7 |
8 | const Bottom = styled.div``;
9 | interface SubThreadListProps {
10 | subThreadList: Thread[] | null;
11 | }
12 |
13 | const SubThreadList: React.FC = ({ subThreadList }: SubThreadListProps) => {
14 | const bottomRef = useRef(null);
15 | return (
16 |
17 | {subThreadList
18 | ?.filter((thread) => !thread.isDeleted)
19 | .map((thread: Thread) => {
20 | return ;
21 | })}
22 |
23 |
24 | );
25 | };
26 |
27 | export default SubThreadList;
28 |
--------------------------------------------------------------------------------
/client/src/components/SubThreadListBox/SubThreadListBox.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import styled from 'styled-components';
3 | import { useSubThreadState } from '@/hooks';
4 | import { useParams } from 'react-router-dom';
5 | import { useDispatch } from 'react-redux';
6 | import { getSubThreadRequest } from '@/store/modules/subThread.slice';
7 | import { isNumberTypeValue } from '@/utils/utils';
8 | import { ThreadInputBox } from '@/components/common';
9 | import { INPUT_BOX_TYPE } from '@/utils/constants';
10 | import ParentThread from './ParentThread/ParentThread';
11 | import SubThreadList from './SubThreadList/SubThreadList';
12 | import ReplyCountHorizon from './ReplyCountHorizon/ReplyCountHorizon';
13 |
14 | const Container = styled.div`
15 | width: 25rem;
16 | display: flex;
17 | flex-direction: column;
18 | `;
19 |
20 | const ListContainer = styled.div`
21 | width: 100%;
22 | display: flex;
23 | flex-direction: column;
24 | flex: 1;
25 | /* overflow-y: auto; */
26 | `;
27 |
28 | interface RightSideParams {
29 | channelId: string | undefined;
30 | rightSideType: string | undefined;
31 | threadId: string | undefined;
32 | }
33 |
34 | const SubThreadListBox: React.FC = () => {
35 | const dispatch = useDispatch();
36 | const { threadId }: RightSideParams = useParams();
37 | const { parentThread, subThreadList } = useSubThreadState();
38 |
39 | useEffect(() => {
40 | if (!Number.isNaN(Number(threadId))) {
41 | dispatch(getSubThreadRequest({ parentId: Number(threadId) }));
42 | }
43 | }, [threadId]);
44 |
45 | return (
46 | <>
47 | {isNumberTypeValue(threadId) && parentThread !== undefined && (
48 |
49 |
50 |
51 | {parentThread.subCount > 0 && }
52 |
53 |
54 |
55 |
56 | )}
57 | >
58 | );
59 | };
60 |
61 | export default SubThreadListBox;
62 |
--------------------------------------------------------------------------------
/client/src/components/SubThreadListBox/SubThreadListHeader/SubThreadListHeader.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import { useDispatch } from 'react-redux';
3 | import { useChannelState, useUserState } from '@/hooks';
4 | import styled from 'styled-components';
5 | import { useParams } from 'react-router-dom';
6 | import { loadChannelRequest } from '@/store/modules/channel.slice';
7 | import { LockIcon, PoundIcon } from '@/components';
8 | import theme from '@/styles/theme';
9 | import { flex } from '@/styles/mixin';
10 |
11 | const Container = styled.div`
12 | ${flex('center', 'space-between')}
13 | width: 100%;
14 | height: 4.3rem;
15 | flex-shrink: 0;
16 | background-color: white;
17 | `;
18 |
19 | const LeftBox = styled.div``;
20 |
21 | const LeftTopBox = styled.div`
22 | font-weight: 800;
23 | `;
24 | const LeftBottomBox = styled.div`
25 | width: 16rem;
26 | overflow: hidden;
27 | text-overflow: ellipsis;
28 | white-space: nowrap;
29 | `;
30 |
31 | const ChannelTitle = styled.span`
32 | color: ${(props) => props.theme.color.black5};
33 | font-size: 0.8rem;
34 | font-weight: 200;
35 | margin-left: 0.2rem;
36 | `;
37 |
38 | interface RightSideParams {
39 | channelId: string;
40 | }
41 |
42 | const SubThreadListHeader = () => {
43 | const dispatch = useDispatch();
44 | const { current } = useChannelState();
45 | const { userInfo } = useUserState();
46 | const { channelId }: RightSideParams = useParams();
47 |
48 | useEffect(() => {
49 | if (userInfo) {
50 | dispatch(loadChannelRequest({ channelId: +channelId, userId: userInfo.id }));
51 | }
52 | }, [channelId, userInfo]);
53 |
54 | return (
55 |
56 |
57 | Thread
58 |
59 | {current?.isPublic ? (
60 |
61 | ) : (
62 |
63 | )}
64 | {current?.name}
65 |
66 |
67 |
68 | );
69 | };
70 |
71 | export default SubThreadListHeader;
72 |
--------------------------------------------------------------------------------
/client/src/components/ThreadListBox/ThreadListBox.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import styled from 'styled-components';
3 | import { useDispatch } from 'react-redux';
4 | import { useParams } from 'react-router-dom';
5 | import { INPUT_BOX_TYPE, SOCKET_MESSAGE_TYPE } from '@/utils/constants';
6 | import { ThreadInputBox } from '@/components';
7 | import { loadChannelRequest } from '@/store/modules/channel.slice';
8 | import {
9 | enterRoomRequest,
10 | leaveRoomRequest,
11 | sendMessageRequest,
12 | } from '@/store/modules/socket.slice';
13 | import { useChannelState, useSocketState, useUserState } from '@/hooks';
14 | import { isNumberTypeValue } from '@/utils/utils';
15 | import { getThreadRequest } from '@/store/modules/thread.slice';
16 | import ThreadList from './ThreadList/ThreadList';
17 | import ThreadListHeader from './ThreadListHeader/ThreadListHeader';
18 |
19 | const Container = styled.div`
20 | width: 100%;
21 | flex: 1;
22 | display: flex;
23 | flex-direction: column;
24 | border-right: 1px solid ${(props) => props.theme.color.lightGray2};
25 | `;
26 |
27 | interface RightSideParams {
28 | channelId: string;
29 | threadId: string;
30 | }
31 |
32 | const ThreadListBox = () => {
33 | const { channelId, threadId }: RightSideParams = useParams();
34 |
35 | const dispatch = useDispatch();
36 |
37 | const { current } = useChannelState();
38 | const { userInfo, edit } = useUserState();
39 | const { socket } = useSocketState();
40 |
41 | useEffect(() => {
42 | if (isNumberTypeValue(channelId)) {
43 | if (userInfo) {
44 | dispatch(loadChannelRequest({ channelId: +channelId, userId: userInfo.id }));
45 | }
46 | }
47 | }, [channelId, userInfo]);
48 |
49 | useEffect(() => {
50 | if (Number.isInteger(+channelId)) {
51 | dispatch(getThreadRequest({ channelId: +channelId }));
52 | }
53 | }, [channelId]);
54 |
55 | useEffect(() => {
56 | if (edit.success && userInfo) {
57 | dispatch(
58 | sendMessageRequest({
59 | type: SOCKET_MESSAGE_TYPE.USER,
60 | user: userInfo,
61 | parentThreadId: threadId,
62 | }),
63 | );
64 | }
65 | }, [edit]);
66 |
67 | useEffect(() => {
68 | if (current && socket) {
69 | dispatch(enterRoomRequest({ room: current.name }));
70 | }
71 | return () => {
72 | if (current && socket) {
73 | dispatch(leaveRoomRequest({ room: current.name }));
74 | }
75 | };
76 | }, [current, socket]);
77 |
78 | return (
79 |
80 |
81 |
82 |
83 |
84 | );
85 | };
86 |
87 | export default ThreadListBox;
88 |
--------------------------------------------------------------------------------
/client/src/components/ThreadListBox/ThreadListHeader/ChannelModal/AddTopicModal/AddTopicModalBody/AddTopicModalBody.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef, useState } from 'react';
2 | import styled from 'styled-components';
3 | import { flex } from '@/styles/mixin';
4 | import { useDispatch } from 'react-redux';
5 | import { useChannelState } from '@/hooks';
6 | import { sendMessageRequest } from '@/store/modules/socket.slice';
7 | import { SOCKET_MESSAGE_TYPE, CHANNEL_SUBTYPE } from '@/utils/constants';
8 | import { FormInput, SubmitButton as SB, CancelButton as CB } from '@/styles/shared';
9 |
10 | const ModalBody = styled.div`
11 | ${flex()};
12 | padding: 0 1.4rem;
13 | `;
14 |
15 | const TextArea = styled(FormInput)`
16 | width: 100%;
17 | height: 90px;
18 | font-size: ${(props) => props.theme.size.m};
19 | resize: none;
20 | `;
21 |
22 | const ModalFooter = styled.div`
23 | width: 100%;
24 | height: 100%;
25 | margin: 1.4rem 0;
26 | padding: 0 1rem;
27 | border-radius: 0 0 5px 5px;
28 | ${flex('center', 'flex-end')};
29 | `;
30 |
31 | const CancelButton = styled(CB)`
32 | font-weight: 800;
33 | margin: 0 0.5rem;
34 | `;
35 |
36 | const SubmitButton = styled(SB)`
37 | font-weight: 800;
38 | margin: 0 0.5rem;
39 | `;
40 |
41 | interface AddTopicModalBodyProps {
42 | setAddTopicModalVisible: (fn: (state: boolean) => boolean) => void;
43 | }
44 |
45 | const AddTopicModalBody: React.FC = ({
46 | setAddTopicModalVisible,
47 | }: AddTopicModalBodyProps) => {
48 | const { current } = useChannelState();
49 | const [content, setContent] = useState(current?.topic ?? '');
50 | const dispatch = useDispatch();
51 |
52 | const changeContent = (e: React.ChangeEvent) => {
53 | const { value } = e.target;
54 | setContent(value);
55 | };
56 |
57 | const clickCancel = () => {
58 | setAddTopicModalVisible((state: boolean) => !state);
59 | };
60 |
61 | const clickSubmit = () => {
62 | if (!content.trim()) {
63 | alert('토픽을 입력해주세요');
64 | return;
65 | }
66 | if (current?.id) {
67 | dispatch(
68 | sendMessageRequest({
69 | type: SOCKET_MESSAGE_TYPE.CHANNEL,
70 | subType: CHANNEL_SUBTYPE.UPDATE_CHANNEL_TOPIC,
71 | channel: { ...current, topic: content },
72 | room: current?.name as string,
73 | }),
74 | );
75 | setAddTopicModalVisible((state: boolean) => !state);
76 | }
77 | };
78 |
79 | const textareaRef = useRef(null);
80 |
81 | useEffect(() => {
82 | textareaRef.current?.focus();
83 | }, []);
84 |
85 | return (
86 | <>
87 |
88 |
89 |
90 |
91 | Cancel
92 | Set Topic
93 |
94 | >
95 | );
96 | };
97 |
98 | export default AddTopicModalBody;
99 |
--------------------------------------------------------------------------------
/client/src/components/ThreadListBox/ThreadListHeader/ChannelModal/AddTopicModal/AddTopicModalHeader/AddTopicModalHeader.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 |
4 | const HeaderContent = styled.div`
5 | font-size: 1.4rem;
6 | font-weight: 800;
7 | color: ${(props) => props.theme.color.lightBlack};
8 | padding: 0.5rem 0;
9 | `;
10 |
11 | const AddTopicModalHeader: React.FC = () => {
12 | return Edit channel topic;
13 | };
14 |
15 | export default AddTopicModalHeader;
16 |
--------------------------------------------------------------------------------
/client/src/components/ThreadListBox/ThreadListHeader/ChannelModal/AddTopicModal/index.ts:
--------------------------------------------------------------------------------
1 | export { default as AddTopicModalHeader } from './AddTopicModalHeader/AddTopicModalHeader';
2 | export { default as AddTopicModalBody } from './AddTopicModalBody/AddTopicModalBody';
3 |
--------------------------------------------------------------------------------
/client/src/components/ThreadListBox/ThreadListHeader/ChannelModal/ShowUsersModal/ShowUsersModalBody/ShowUsersModalBody.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { flex } from '@/styles/mixin';
4 | import { useChannelState } from '@/hooks';
5 | import { JoinedUser } from '@/types';
6 | import { CancelButton as CB } from '@/styles/shared';
7 | import { USER_DEFAULT_PROFILE_URL } from '@/utils/constants';
8 | import { LazyImage } from '@/components/common';
9 |
10 | const Container = styled.div`
11 | padding: 0 4px;
12 | margin: 1rem 0 3rem 0;
13 | `;
14 |
15 | const Remove = styled(CB)`
16 | font-size: 1rem;
17 | font-weight: bold;
18 | background: ${(props) => props.theme.color.white};
19 | border: 1px ${(props) => props.theme.color.gray3} solid;
20 | border-radius: 3px;
21 | margin-left: auto;
22 | `;
23 |
24 | const SearchedUserContainer = styled.div`
25 | width: 100%;
26 | height: 15rem;
27 | overflow-y: auto;
28 | padding: 0 24px;
29 | `;
30 |
31 | const SearchedUserBox = styled.div`
32 | padding: 8px;
33 | margin: 0.5rem 0;
34 | border-radius: 5px;
35 | ${flex('center', 'flex-start')}
36 | cursor: pointer;
37 | color: ${(props) => props.theme.color.lightBlack};
38 | &:hover {
39 | color: white;
40 | background-color: ${(props) => props.theme.color.blue1};
41 | }
42 | `;
43 |
44 | const UserName = styled.div`
45 | font-size: 1rem;
46 | margin: 0 0.7rem;
47 | padding-bottom: 0.25rem;
48 | font-weight: bold;
49 | overflow: hidden;
50 | text-overflow: ellipsis;
51 | white-space: nowrap;
52 | `;
53 |
54 | const ShowUsersModalBody: React.FC = () => {
55 | const { users } = useChannelState();
56 |
57 | return (
58 |
59 |
60 | {users?.map((user: JoinedUser) => (
61 |
62 |
69 | {user.displayName}
70 |
71 | ))}
72 |
73 |
74 | );
75 | };
76 |
77 | export default ShowUsersModalBody;
78 |
--------------------------------------------------------------------------------
/client/src/components/ThreadListBox/ThreadListHeader/ChannelModal/ShowUsersModal/ShowUsersModalHeader/ShowUsersModalHeader.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { flex } from '@/styles/mixin';
4 | import theme from '@/styles/theme';
5 | import { useChannelState } from '@/hooks';
6 | import { LockIcon, PoundIcon } from '@/components';
7 |
8 | const Container = styled.div`
9 | ${flex('center', 'flex-start')}
10 | width: 100%;
11 | height: 3.2rem;
12 | flex-shrink: 0;
13 | font-size: 1.4rem;
14 | font-weight: 800;
15 | background-color: white;
16 | color: ${(props) => props.theme.color.lightBlack};
17 | `;
18 |
19 | const Icon = styled.span`
20 | margin-left: 0.5rem;
21 | `;
22 |
23 | const ShowUsersModalHeader: React.FC = () => {
24 | const { users, current } = useChannelState();
25 |
26 | return (
27 |
28 | {users.length} members in{' '}
29 |
30 | {current?.isPublic ? (
31 |
32 | ) : (
33 |
34 | )}{' '}
35 |
36 | {current?.name}
37 |
38 | );
39 | };
40 |
41 | export default ShowUsersModalHeader;
42 |
--------------------------------------------------------------------------------
/client/src/components/ThreadListBox/ThreadListHeader/ChannelModal/ShowUsersModal/index.ts:
--------------------------------------------------------------------------------
1 | export { default as ShowUsersModalHeader } from './ShowUsersModalHeader/ShowUsersModalHeader';
2 | export { default as ShowUsersModalBody } from './ShowUsersModalBody/ShowUsersModalBody';
3 |
--------------------------------------------------------------------------------
/client/src/components/common/AddUsersModal/AddUsersModalHeader/AddUsersModalHeader.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { flex } from '@/styles/mixin';
4 | import { useChannelState } from '@/hooks';
5 | import theme from '@/styles/theme';
6 | import { LockIcon, PoundIcon } from '@/components';
7 |
8 | const Container = styled.div`
9 | ${flex('center', 'space-between')}
10 | width: 100%;
11 | height: 3rem;
12 | flex-shrink: 0;
13 | font-size: 1.2rem;
14 | font-weight: 800;
15 | background-color: white;
16 | `;
17 |
18 | const LeftBox = styled.div``;
19 |
20 | const LeftTopBox = styled.div`
21 | font-weight: 800;
22 | color: ${(props) => props.theme.color.lightBlack};
23 | `;
24 | const LeftBottomBox = styled.div`
25 | width: 16rem;
26 | overflow: hidden;
27 | text-overflow: ellipsis;
28 | white-space: nowrap;
29 | `;
30 |
31 | const ChannelTitle = styled.span`
32 | color: ${(props) => props.theme.color.black5};
33 | font-size: 0.8rem;
34 | font-weight: 200;
35 | margin-left: 0.2rem;
36 | `;
37 |
38 | interface AddUsersModalHeaderProps {
39 | isDM: boolean;
40 | }
41 |
42 | const AddUserModalHeader: React.FC = ({
43 | isDM,
44 | }: AddUsersModalHeaderProps) => {
45 | const { current } = useChannelState();
46 | return (
47 |
48 |
49 | Add people
50 | {!isDM && (
51 |
52 | {current?.isPublic ? (
53 |
54 | ) : (
55 |
56 | )}
57 | {current?.name}
58 |
59 | )}
60 |
61 |
62 | );
63 | };
64 |
65 | export default AddUserModalHeader;
66 |
--------------------------------------------------------------------------------
/client/src/components/common/AddUsersModal/index.ts:
--------------------------------------------------------------------------------
1 | export { default as AddUsersModalHeader } from './AddUsersModalHeader/AddUsersModalHeader';
2 | export { default as AddUsersModalBody } from './AddUsersModalBody/AddUsersModalBody';
3 |
--------------------------------------------------------------------------------
/client/src/components/common/CloseIconBox/CloseIconBox.tsx:
--------------------------------------------------------------------------------
1 | import React, { PropsWithChildren } from 'react';
2 | import styled from 'styled-components';
3 |
4 | const Container = styled.div`
5 | width: 2rem;
6 | height: 2rem;
7 | display: flex;
8 | justify-content: center;
9 | align-items: center;
10 | border-radius: 5px;
11 | cursor: pointer;
12 | &:hover,
13 | &:active {
14 | background-color: ${(props) => props.theme.color.gray5};
15 | path {
16 | fill: ${(props) => props.theme.color.black4};
17 | }
18 | }
19 | &:active {
20 | background-color: ${(props) => props.theme.color.gray4};
21 | }
22 | `;
23 |
24 | const Icon = styled.svg`
25 | width: 12px;
26 | height: 12px;
27 | `;
28 |
29 | const Path = styled.path`
30 | fill: ${(props) => props.color ?? props.theme.color.black8};
31 | `;
32 |
33 | const CloseIconBox: React.FC = ({ color }: PropsWithChildren<{ color?: string }>) => {
34 | return (
35 |
36 |
37 |
41 |
42 |
43 | );
44 | };
45 |
46 | export default CloseIconBox;
47 |
--------------------------------------------------------------------------------
/client/src/components/common/DimModal/DimModal.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef, PropsWithChildren } from 'react';
2 | import styled, { css } from 'styled-components';
3 | import { flex } from '@/styles/mixin';
4 | import { ModalCloseBox } from '@/components';
5 | import { useOnClickOutside } from '@/hooks';
6 |
7 | interface DimLayerProps {
8 | visible: boolean;
9 | }
10 |
11 | const DimLayer = styled.div`
12 | position: fixed;
13 | top: 0;
14 | left: 0;
15 | ${flex()};
16 | ${(props) =>
17 | !props.visible &&
18 | css`
19 | display: none;
20 | `}
21 | width: 100%;
22 | height: 100%;
23 | background-color: rgba(0, 0, 0, 0.55);
24 | z-index: 10;
25 | `;
26 |
27 | interface ContainerProps {
28 | width?: string;
29 | height?: string;
30 | }
31 |
32 | const Container = styled.div`
33 | ${flex('center', 'center', 'column')}
34 | width: ${(props) => props.width ?? '43rem'};
35 | height: auto;
36 | max-height: 90vh;
37 | background-color: white;
38 | border-radius: 5px;
39 | outline: 0;
40 | overflow: hidden;
41 | `;
42 |
43 | const Header = styled.div`
44 | position: relative;
45 | ${flex()}
46 | width: 100%;
47 | padding: 12px 28px;
48 | border-radius: 5px 5px 0 0;
49 | overflow: hidden;
50 | `;
51 |
52 | const Title = styled.div`
53 | flex: 1;
54 | `;
55 |
56 | interface BodyProps {
57 | bodyScroll: boolean;
58 | }
59 |
60 | const Body = styled.div`
61 | width: 100%;
62 | border-radius: 0 0 5px 5px;
63 | ${(props) =>
64 | props.bodyScroll &&
65 | css`
66 | overflow: auto;
67 | `}
68 | `;
69 |
70 | const CloseIcon = styled.div`
71 | position: absolute;
72 | right: 20px;
73 | `;
74 |
75 | interface DimModalProps {
76 | header: React.ReactNode;
77 | body: React.ReactNode;
78 | visible: boolean;
79 | setVisible: (a: any) => any;
80 | width?: string;
81 | bodyScroll?: boolean;
82 | }
83 |
84 | const DimModal: React.FC> = ({
85 | header,
86 | body,
87 | visible,
88 | setVisible,
89 | width,
90 | bodyScroll = true,
91 | }: PropsWithChildren) => {
92 | const containerRef = useRef(null);
93 |
94 | const handleClose = (e: React.MouseEvent) => {
95 | e.stopPropagation();
96 | setVisible(false);
97 | };
98 |
99 | useOnClickOutside(containerRef, () => setVisible(false));
100 |
101 | useEffect(() => {
102 | containerRef.current?.focus();
103 | }, []);
104 |
105 | return (
106 |
107 |
108 |
109 | {header}
110 |
111 |
112 |
113 |
114 | {body}
115 |
116 |
117 | );
118 | };
119 |
120 | export default DimModal;
121 |
--------------------------------------------------------------------------------
/client/src/components/common/EmojiListModal/EmojiListModal.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { Thread } from '@/types/thread';
4 | import { useChannelState, useEmojiState, useUserState } from '@/hooks';
5 | import { useDispatch } from 'react-redux';
6 | import { sendMessageRequest } from '@/store/modules/socket.slice';
7 | import { SOCKET_MESSAGE_TYPE } from '@/utils/constants';
8 |
9 | const Container = styled.div`
10 | display: grid;
11 | grid-template-columns: repeat(6, 1fr);
12 | `;
13 |
14 | const EmojiBox = styled.div`
15 | cursor: pointer;
16 | transition: 0.3;
17 | &:hover {
18 | transition: 0.3;
19 | background-color: #b5e0fe;
20 | }
21 | border-radius: 5px;
22 | `;
23 |
24 | const Emoji = styled.img`
25 | width: 22px;
26 | height: 22px;
27 | margin: 0.2rem;
28 | border-radius: 5px;
29 | user-select: none;
30 | cursor: pointer;
31 | `;
32 |
33 | interface EmojiListModalProps {
34 | thread: Thread;
35 | }
36 |
37 | const EmojiListModal: React.FC = ({ thread }: EmojiListModalProps) => {
38 | const { userInfo } = useUserState();
39 | const { current } = useChannelState();
40 | const { emojiList } = useEmojiState();
41 | const dispatch = useDispatch();
42 |
43 | const clickEmojiHandler = (emojiId: number) => {
44 | if (userInfo) {
45 | dispatch(
46 | sendMessageRequest({
47 | type: SOCKET_MESSAGE_TYPE.EMOJI,
48 | emojiId,
49 | userId: Number(userInfo.id),
50 | threadId: Number(thread.id),
51 | room: current?.name as string,
52 | }),
53 | );
54 | }
55 | };
56 |
57 | return (
58 |
59 | {emojiList?.map((emoji) => {
60 | return (
61 |
62 | clickEmojiHandler(Number(emoji.id))} />
63 |
64 | );
65 | })}
66 |
67 | );
68 | };
69 |
70 | export default EmojiListModal;
71 |
--------------------------------------------------------------------------------
/client/src/components/common/Header/UserProfileBox/UserProfileHeader/UserProfileHeader.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 |
4 | const ModalHeader = styled.div`
5 | font-size: 1.6rem;
6 | font-weight: 800;
7 | padding: 1.5rem;
8 | padding-left: 1.7rem;
9 | `;
10 |
11 | const UserProfileModalHeader: React.FC = () => {
12 | return Edit your profile;
13 | };
14 |
15 | export default UserProfileModalHeader;
16 |
--------------------------------------------------------------------------------
/client/src/components/common/Header/UserProfileBox/index.ts:
--------------------------------------------------------------------------------
1 | export { default as UserProfileModalHeader } from './UserProfileHeader/UserProfileHeader';
2 | export { default as UserProfileModalBody } from './UserProfileBody/UserProfileBody';
3 |
--------------------------------------------------------------------------------
/client/src/components/common/Icon/AddUserIcon/AddUserIcon.tsx:
--------------------------------------------------------------------------------
1 | import React, { PropsWithChildren } from 'react';
2 | import styled from 'styled-components';
3 |
4 | const Path = styled.path`
5 | fill: ${(props) => props.color ?? props.theme.color.black5};
6 | `;
7 |
8 | const AddUserIcon = ({ color, size }: PropsWithChildren<{ color?: string; size?: string }>) => {
9 | return (
10 |
16 | );
17 | };
18 |
19 | export default AddUserIcon;
20 |
--------------------------------------------------------------------------------
/client/src/components/common/Icon/ArrowDownIcon/ArrowDownIcon.tsx:
--------------------------------------------------------------------------------
1 | import React, { PropsWithChildren } from 'react';
2 | import styled from 'styled-components';
3 |
4 | const Path = styled.path`
5 | fill: ${(props) => props.color ?? props.theme.color.gray2};
6 | `;
7 |
8 | const ArrowDownIcon = ({ color, size }: PropsWithChildren<{ color?: string; size?: string }>) => {
9 | return (
10 |
16 | );
17 | };
18 |
19 | export default ArrowDownIcon;
20 |
--------------------------------------------------------------------------------
/client/src/components/common/Icon/ArrowRightIcon/ArrowRightIcon.tsx:
--------------------------------------------------------------------------------
1 | import React, { PropsWithChildren } from 'react';
2 | import styled from 'styled-components';
3 |
4 | const Path = styled.path`
5 | fill: ${(props) => props.color ?? props.theme.color.gray1};
6 | `;
7 |
8 | const ArrowRightIcon = ({ color, size }: PropsWithChildren<{ color?: string; size?: string }>) => {
9 | return (
10 |
16 | );
17 | };
18 |
19 | export default ArrowRightIcon;
20 |
--------------------------------------------------------------------------------
/client/src/components/common/Icon/ChannelLockIcon/ChannelLockIcon.tsx:
--------------------------------------------------------------------------------
1 | import React, { PropsWithChildren } from 'react';
2 |
3 | const ChannelLockIcon = ({ size }: PropsWithChildren<{ size?: string }>) => {
4 | return (
5 |
20 | );
21 | };
22 |
23 | export default ChannelLockIcon;
24 |
--------------------------------------------------------------------------------
/client/src/components/common/Icon/ClockIcon/ClockIcon.tsx:
--------------------------------------------------------------------------------
1 | import React, { PropsWithChildren } from 'react';
2 | import styled from 'styled-components';
3 |
4 | const Path = styled.path`
5 | fill: ${(props) => props.color ?? props.theme.color.gray3};
6 | `;
7 |
8 | const ClockIcon = ({ color }: PropsWithChildren<{ color?: string }>) => {
9 | return (
10 |
16 | );
17 | };
18 |
19 | export default ClockIcon;
20 |
--------------------------------------------------------------------------------
/client/src/components/common/Icon/CommentIcon/CommentIcon.tsx:
--------------------------------------------------------------------------------
1 | import React, { PropsWithChildren } from 'react';
2 | import styled from 'styled-components';
3 |
4 | const Path = styled.path`
5 | fill: ${(props) => props.color ?? props.theme.color.black5};
6 | `;
7 |
8 | const CommentIcon = ({ color, size }: PropsWithChildren<{ color?: string; size?: string }>) => {
9 | return (
10 |
17 | );
18 | };
19 |
20 | export default CommentIcon;
21 |
--------------------------------------------------------------------------------
/client/src/components/common/Icon/DotIcon/DotIcon.tsx:
--------------------------------------------------------------------------------
1 | import React, { PropsWithChildren } from 'react';
2 | import styled from 'styled-components';
3 |
4 | const Path = styled.path`
5 | stroke: ${(props) => props.color ?? props.theme.color.channelItemColor};
6 | fill: ${(props) => props.color ?? props.theme.color.channelItemColor};
7 | `;
8 |
9 | const DotIcon = ({ color, size }: PropsWithChildren<{ color?: string; size?: string }>) => {
10 | return (
11 |
17 | );
18 | };
19 |
20 | export default DotIcon;
21 |
--------------------------------------------------------------------------------
/client/src/components/common/Icon/GoogleLogoIcon/GoogleLogoIcon.tsx:
--------------------------------------------------------------------------------
1 | import React, { PropsWithChildren } from 'react';
2 |
3 | const GoogleLogoIcon = ({ size }: PropsWithChildren<{ size?: string }>) => {
4 | return (
5 |
26 | );
27 | };
28 |
29 | export default GoogleLogoIcon;
30 |
--------------------------------------------------------------------------------
/client/src/components/common/Icon/LockIcon/LockIcon.tsx:
--------------------------------------------------------------------------------
1 | import React, { PropsWithChildren } from 'react';
2 | import styled from 'styled-components';
3 |
4 | const Path = styled.path`
5 | stroke: ${(props) => props.color ?? props.theme.color.channelItemColor};
6 | fill: ${(props) => props.color ?? props.theme.color.channelItemColor};
7 | `;
8 |
9 | const LockIcon = ({ color, size }: PropsWithChildren<{ color?: string; size?: string }>) => {
10 | return (
11 |
17 | );
18 | };
19 |
20 | export default LockIcon;
21 |
--------------------------------------------------------------------------------
/client/src/components/common/Icon/PaperPlaneIcon/PaperPlaneIcon.tsx:
--------------------------------------------------------------------------------
1 | import React, { PropsWithChildren } from 'react';
2 | import styled from 'styled-components';
3 |
4 | const Path = styled.path`
5 | stroke: ${(props) => props.color ?? props.theme.color.channelItemColor};
6 | fill: ${(props) => props.color ?? props.theme.color.channelItemColor};
7 | `;
8 |
9 | const PaperPlaneIcon = ({ color, size }: PropsWithChildren<{ color?: string; size?: string }>) => {
10 | return (
11 |
18 | );
19 | };
20 |
21 | export default PaperPlaneIcon;
22 |
--------------------------------------------------------------------------------
/client/src/components/common/Icon/PlusIcon/PlusIcon.tsx:
--------------------------------------------------------------------------------
1 | import React, { PropsWithChildren } from 'react';
2 | import styled from 'styled-components';
3 |
4 | const Path = styled.path`
5 | stroke: ${(props) => props.color ?? props.theme.color.channelItemColor};
6 | fill: ${(props) => props.color ?? props.theme.color.channelItemColor};
7 | `;
8 |
9 | const PlusIcon = ({ color, size }: PropsWithChildren<{ color?: string; size?: string }>) => {
10 | return (
11 |
21 | );
22 | };
23 |
24 | export default PlusIcon;
25 |
--------------------------------------------------------------------------------
/client/src/components/common/Icon/PoundIcon/PoundIcon.tsx:
--------------------------------------------------------------------------------
1 | import React, { PropsWithChildren } from 'react';
2 | import styled from 'styled-components';
3 |
4 | const Path = styled.path`
5 | stroke: ${(props) => props.color ?? props.theme.color.channelItemColor};
6 | fill: ${(props) => props.color ?? props.theme.color.channelItemColor};
7 | `;
8 |
9 | const PoundIcon = ({ color, size }: PropsWithChildren<{ color?: string; size?: string }>) => {
10 | return (
11 |
18 | );
19 | };
20 |
21 | export default PoundIcon;
22 |
--------------------------------------------------------------------------------
/client/src/components/common/Icon/ReactionIcon/ReactionIcon.tsx:
--------------------------------------------------------------------------------
1 | import React, { PropsWithChildren } from 'react';
2 | import styled from 'styled-components';
3 |
4 | const Path = styled.path`
5 | fill: ${(props) => props.color ?? props.theme.color.black5};
6 | `;
7 |
8 | const ReactionIcon = ({ color, size }: PropsWithChildren<{ color?: string; size?: string }>) => {
9 | return (
10 |
16 | );
17 | };
18 |
19 | export default ReactionIcon;
20 |
--------------------------------------------------------------------------------
/client/src/components/common/Icon/RightArrowLineIcon/RightArrowLineIcon.tsx:
--------------------------------------------------------------------------------
1 | import React, { PropsWithChildren } from 'react';
2 | import styled from 'styled-components';
3 |
4 | const Path = styled.path`
5 | fill: ${(props) => props.color ?? props.theme.color.black5};
6 | `;
7 |
8 | const RightArrowLineIcon = ({
9 | color,
10 | size,
11 | }: PropsWithChildren<{ color?: string; size?: string }>) => {
12 | return (
13 |
19 | );
20 | };
21 |
22 | export default RightArrowLineIcon;
23 |
--------------------------------------------------------------------------------
/client/src/components/common/Icon/RightIcon/RightIcon.tsx:
--------------------------------------------------------------------------------
1 | import React, { PropsWithChildren } from 'react';
2 | import styled from 'styled-components';
3 |
4 | const Path = styled.path`
5 | fill: ${(props) => props.color ?? props.theme.color.black5};
6 | `;
7 | const RightIcon = ({ color, size }: PropsWithChildren<{ color?: string; size?: string }>) => {
8 | return (
9 |
15 | );
16 | };
17 |
18 | export default RightIcon;
19 |
--------------------------------------------------------------------------------
/client/src/components/common/Icon/TrashIcon/TrashIcon.tsx:
--------------------------------------------------------------------------------
1 | import React, { PropsWithChildren } from 'react';
2 | import styled from 'styled-components';
3 |
4 | const Path = styled.path`
5 | fill: ${(props) => props.color ?? props.theme.color.black5};
6 | `;
7 |
8 | const TrashIcon = ({ color, size }: PropsWithChildren<{ color?: string; size?: string }>) => {
9 | return (
10 |
16 | );
17 | };
18 |
19 | export default TrashIcon;
20 |
--------------------------------------------------------------------------------
/client/src/components/common/Icon/UserStateIcon/UserStateIcon.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import theme from '@/styles/theme';
4 |
5 | interface UserStateIconProps {
6 | width?: string;
7 | height?: string;
8 | color?: string;
9 | }
10 |
11 | export const CircleIcon = styled.div`
12 | width: ${(props) => props.width};
13 | height: ${(props) => props.height};
14 | background-color: ${(props) => props.color};
15 | border-radius: 50%;
16 | `;
17 |
18 | const UserStateIcon: React.FC = ({
19 | width,
20 | height,
21 | color,
22 | }: UserStateIconProps) => {
23 | return ;
24 | };
25 |
26 | UserStateIcon.defaultProps = {
27 | width: '0.6rem',
28 | height: '0.6rem',
29 | color: theme.color.onConnect,
30 | };
31 | export default UserStateIcon;
32 |
--------------------------------------------------------------------------------
/client/src/components/common/Icon/WarningIcon/WarningIcon.tsx:
--------------------------------------------------------------------------------
1 | import React, { PropsWithChildren } from 'react';
2 | import styled from 'styled-components';
3 |
4 | const Path = styled.path`
5 | fill: ${(props) => props.color ?? props.theme.color.warningRed};
6 | `;
7 |
8 | const WarningIcon = ({ color, size }: PropsWithChildren<{ color?: string; size?: string }>) => {
9 | return (
10 |
17 | );
18 | };
19 |
20 | export default WarningIcon;
21 |
--------------------------------------------------------------------------------
/client/src/components/common/Icon/WriteIcon/WriteIcon.tsx:
--------------------------------------------------------------------------------
1 | import React, { PropsWithChildren } from 'react';
2 | import styled from 'styled-components';
3 |
4 | const Path = styled.path`
5 | fill: ${(props) => props.color ?? props.theme.color.purple1};
6 | `;
7 |
8 | const WriteIcon = ({ color, size }: PropsWithChildren<{ color?: string; size?: string }>) => {
9 | return (
10 |
17 | );
18 | };
19 |
20 | export default WriteIcon;
21 |
--------------------------------------------------------------------------------
/client/src/components/common/Icon/index.ts:
--------------------------------------------------------------------------------
1 | export { default as ClockIcon } from './ClockIcon/ClockIcon';
2 | export { default as UserStateIcon } from './UserStateIcon/UserStateIcon';
3 | export { default as WarningIcon } from './WarningIcon/WarningIcon';
4 | export { default as WriteIcon } from './WriteIcon/WriteIcon';
5 | export { default as ArrowDownIcon } from './ArrowDownIcon/ArrowDownIcon';
6 | export { default as ArrowRightIcon } from './ArrowRightIcon/ArrowRightIcon';
7 | export { default as PlusIcon } from './PlusIcon/PlusIcon';
8 | export { default as DotIcon } from './DotIcon/DotIcon';
9 | export { default as LockIcon } from './LockIcon/LockIcon';
10 | export { default as PoundIcon } from './PoundIcon/PoundIcon';
11 | export { default as AddUserIcon } from './AddUserIcon/AddUserIcon';
12 | export { default as ReactionIcon } from './ReactionIcon/ReactionIcon';
13 | export { default as CommentIcon } from './CommentIcon/CommentIcon';
14 | export { default as PaperPlaneIcon } from './PaperPlaneIcon/PaperPlaneIcon';
15 | export { default as RightIcon } from './RightIcon/RightIcon';
16 | export { default as RightArrowLineIcon } from './RightArrowLineIcon/RightArrowLineIcon';
17 | export { default as GoogleLogoIcon } from './GoogleLogoIcon/GoogleLogoIcon';
18 | export { default as TrashIcon } from './TrashIcon/TrashIcon';
19 | export { default as LightIcon } from './LightIcon/LightIcon';
20 | export { default as ChannelLockIcon } from './ChannelLockIcon/ChannelLockIcon';
21 | export { default as ConversationIcon } from './ConversationIcon/ConversationIcon';
22 |
--------------------------------------------------------------------------------
/client/src/components/common/LazyImage/LazyImage.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef, useState } from 'react';
2 | import styled from 'styled-components';
3 |
4 | interface Props {
5 | src: string;
6 | width: string;
7 | height: string;
8 | style?: any;
9 | errorImage?: string;
10 | }
11 |
12 | const Img = styled.img`
13 | object-fit: cover;
14 | border-radius: 5px;
15 | `;
16 |
17 | const LazyImage: React.FC = ({ src, width, height, style, errorImage }: Props) => {
18 | const imgRef = useRef(null);
19 | const [isLoad, setIsLoad] = useState(false);
20 |
21 | function onIntersection(entries: IntersectionObserverEntry[], io: IntersectionObserver) {
22 | entries.forEach((entry) => {
23 | if (entry.isIntersecting) {
24 | io.unobserve(entry.target);
25 | setIsLoad(true);
26 | }
27 | });
28 | }
29 |
30 | useEffect(() => {
31 | const observer = new IntersectionObserver(onIntersection, {
32 | threshold: 0,
33 | });
34 |
35 | if (imgRef.current) {
36 | observer.observe(imgRef.current);
37 | }
38 | }, [imgRef]);
39 |
40 | return (
41 |
) => {
48 | e.currentTarget.src = errorImage ?? `https://via.placeholder.com/${width}x${height}`;
49 | }}
50 | />
51 | );
52 | };
53 |
54 | LazyImage.defaultProps = {
55 | style: {},
56 | errorImage: undefined,
57 | };
58 |
59 | export default LazyImage;
60 |
--------------------------------------------------------------------------------
/client/src/components/common/LogoBox/LogoBox.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import slackLogo from '@/public/icon/slack-logo.webp';
4 | import { flex } from '@/styles/mixin';
5 | import { Link } from 'react-router-dom';
6 |
7 | const Container = styled.div`
8 | width: 100%;
9 | height: 5rem;
10 | ${flex()};
11 | margin-top: 2rem;
12 | user-select: none;
13 | `;
14 |
15 | const SlackLogo = styled.img`
16 | width: 14rem;
17 | margin-right: 3rem;
18 | `;
19 |
20 | const LogoBox: React.FC = () => {
21 | return (
22 |
23 |
24 |
25 |
26 |
27 | );
28 | };
29 |
30 | export default LogoBox;
31 |
--------------------------------------------------------------------------------
/client/src/components/common/ModalCloseBox/ModalCloseBox.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 |
4 | const Container = styled.div`
5 | width: 2rem;
6 | height: 2rem;
7 | display: flex;
8 | justify-content: center;
9 | align-items: center;
10 | border-radius: 5px;
11 | cursor: pointer;
12 | &:hover,
13 | &:active {
14 | background-color: ${(props) => props.theme.color.gray5};
15 | path {
16 | fill: ${(props) => props.theme.color.black4};
17 | }
18 | }
19 | &:active {
20 | background-color: ${(props) => props.theme.color.gray4};
21 | }
22 | `;
23 |
24 | const Icon = styled.svg`
25 | width: 12px;
26 | height: 12px;
27 | `;
28 |
29 | interface ModalCloseBoxProps {
30 | color?: string;
31 | handleClose: (e: React.MouseEvent) => void;
32 | }
33 |
34 | const ModalCloseBox: React.FC = ({
35 | color,
36 | handleClose,
37 | }: ModalCloseBoxProps) => {
38 | return (
39 |
40 |
41 |
45 |
46 |
47 | );
48 | };
49 | ModalCloseBox.defaultProps = { color: '#888888' };
50 |
51 | export default ModalCloseBox;
52 |
--------------------------------------------------------------------------------
/client/src/components/common/RightSideBar/RightSideBar.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import RightSideBarHeader from './RightSideBarHeader/RightSideBarHeader';
4 | import RightSideBarBody from './RightSideBarBody/RightSideBarBody';
5 |
6 | const Container = styled.div`
7 | width: 25rem;
8 | display: flex;
9 | flex-direction: column;
10 | `;
11 |
12 | interface RightSideBarProps {
13 | url: string;
14 | header: React.ReactNode;
15 | body: React.ReactNode;
16 | }
17 |
18 | const RightSideBar: React.FC = ({ url, header, body }: RightSideBarProps) => {
19 | return (
20 |
21 | {header}
22 | {body}
23 |
24 | );
25 | };
26 |
27 | export default RightSideBar;
28 |
--------------------------------------------------------------------------------
/client/src/components/common/RightSideBar/RightSideBarBody/RightSideBarBody.tsx:
--------------------------------------------------------------------------------
1 | import React, { PropsWithChildren } from 'react';
2 | import styled from 'styled-components';
3 |
4 | const Container = styled.div`
5 | width: 100%;
6 | display: flex;
7 | flex-direction: column;
8 | flex: 1;
9 | overflow-y: auto;
10 | `;
11 |
12 | interface RightSideBarBodyProps {
13 | children: React.ReactNode;
14 | }
15 |
16 | const RightSideBarBody: React.FC> = ({
17 | children,
18 | }: PropsWithChildren) => {
19 | return {children};
20 | };
21 |
22 | export default RightSideBarBody;
23 |
--------------------------------------------------------------------------------
/client/src/components/common/RightSideBar/RightSideBarHeader/RightSideBarHeader.tsx:
--------------------------------------------------------------------------------
1 | import React, { PropsWithChildren } from 'react';
2 | import styled from 'styled-components';
3 | import { flex } from '@/styles/mixin';
4 | import { Link } from 'react-router-dom';
5 | import { CloseIconBox } from '@/components';
6 |
7 | const Container = styled.div`
8 | ${flex('center', 'space-between')}
9 | width: 100%;
10 | height: 4.3rem;
11 | flex-shrink: 0;
12 | padding: 0 1.3rem;
13 | border-bottom: 1px solid ${(props) => props.theme.color.lightGray2};
14 | background-color: white;
15 | `;
16 |
17 | const LeftBox = styled.div``;
18 | interface RightSideBarHeaderProps {
19 | url: string;
20 | children: React.ReactNode;
21 | }
22 |
23 | const RightSideBarHeader: React.FC> = ({
24 | url,
25 | children,
26 | }: PropsWithChildren) => {
27 | return (
28 |
29 | {children}
30 |
31 |
32 |
33 |
34 | );
35 | };
36 |
37 | export default RightSideBarHeader;
38 |
--------------------------------------------------------------------------------
/client/src/components/common/ThreadItem/EmojiBox/EmojiBox.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { Thread } from '@/types/thread';
4 | import { flex } from '@/styles/mixin';
5 | import EmojiBoxItem from './EmojiBoxItem/EmojiBoxItem';
6 |
7 | const Container = styled.div`
8 | ${flex('center', 'flex-start', 'row')};
9 | margin: 0.4rem 0 0 0;
10 | `;
11 |
12 | interface EmojiBoxProps {
13 | thread: Thread;
14 | }
15 |
16 | const EmojiBox: React.FC = ({ thread }: EmojiBoxProps) => {
17 | return (
18 |
19 | {thread.emoji.length > 0 &&
20 | thread.emoji.map((emoji) => {
21 | return ;
22 | })}
23 |
24 | );
25 | };
26 |
27 | export default EmojiBox;
28 |
--------------------------------------------------------------------------------
/client/src/components/common/ThreadItem/EmojiBox/EmojiBoxItem/TooltipPopup/TooltipPopup.tsx:
--------------------------------------------------------------------------------
1 | import React, { FC, PropsWithChildren } from 'react';
2 | import styled from 'styled-components';
3 |
4 | interface ContainerProps {
5 | top?: string;
6 | left?: string;
7 | }
8 |
9 | const Container = styled.div`
10 | position: fixed;
11 | top: ${(props) => props.top ?? 0};
12 | left: ${(props) => props.left ?? 0};
13 | border-radius: 5px;
14 | outline: 0;
15 | z-index: 15;
16 | transform: translateY(-100%);
17 | `;
18 |
19 | interface TooltipProps {
20 | anchorEl: HTMLElement;
21 | top?: number;
22 | left?: number;
23 | }
24 |
25 | const TooltipPopup: FC> = ({
26 | children,
27 | anchorEl,
28 | top = 0,
29 | left = 0,
30 | }: PropsWithChildren) => {
31 | const parentBox = anchorEl.getBoundingClientRect();
32 |
33 | const TOP = top + parentBox.top;
34 | const LEFT = left + parentBox.left;
35 |
36 | return (
37 |
38 | {children}
39 |
40 | );
41 | };
42 |
43 | export default TooltipPopup;
44 |
--------------------------------------------------------------------------------
/client/src/components/common/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Icon';
2 | export { default as Header } from './Header/Header';
3 | export { default as ThreadItem } from './ThreadItem/ThreadItem';
4 | export { default as RightSideBar } from './RightSideBar/RightSideBar';
5 | export { default as DimModal } from './DimModal/DimModal';
6 | export { default as ModalCloseBox } from './ModalCloseBox/ModalCloseBox';
7 | export { default as ThreadInputBox } from './ThreadInputBox/ThreadInputBox';
8 | export { default as EmojiListModal } from './EmojiListModal/EmojiListModal';
9 | export { default as Popover } from './Popover/Popover';
10 | export { default as CloseIconBox } from './CloseIconBox/CloseIconBox';
11 | export * from './AddUsersModal';
12 | export { default as LogoBox } from './LogoBox/LogoBox';
13 | export { default as LazyImage } from './LazyImage/LazyImage';
14 |
--------------------------------------------------------------------------------
/client/src/components/index.ts:
--------------------------------------------------------------------------------
1 | export * from './common';
2 | export { default as LoginBox } from './LoginBox/LoginBox';
3 | export { default as LeftSideBar } from './LeftSideBar/LeftSidebar';
4 | export { default as ThreadListBox } from './ThreadListBox/ThreadListBox';
5 | export { default as SubThreadListBox } from './SubThreadListBox/SubThreadListBox';
6 | export { default as DetailHeader } from './DetailBox/DeatailHeader/DetailHeader';
7 | export { default as DetailBody } from './DetailBox/DetailBody/DetailBody';
8 | export { default as EmailBox } from './EmailBox/EmailBox';
9 | export { default as CodeVerifyBox } from './CodeVerifyBox/CodeVerifyBox';
10 | export { default as SubThreadListHeader } from './SubThreadListBox/SubThreadListHeader/SubThreadListHeader';
11 | export { default as SignupBox } from './SignupBox/SignupBox';
12 |
--------------------------------------------------------------------------------
/client/src/config/index.ts:
--------------------------------------------------------------------------------
1 | const config = {
2 | jwtSecret: process.env.JWT_SECRET as string,
3 | jwtRefreshSecret: process.env.JWT_REFRESH_SECRET as string,
4 | GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID as string,
5 | };
6 |
7 | export default config;
8 |
--------------------------------------------------------------------------------
/client/src/fonts/NotoSansKR-Bold.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcamp-2020/Project06-A-Slack/27585ed84e3da4655929fbc9b2fc535e68ec31f7/client/src/fonts/NotoSansKR-Bold.otf
--------------------------------------------------------------------------------
/client/src/fonts/NotoSansKR-Bold.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcamp-2020/Project06-A-Slack/27585ed84e3da4655929fbc9b2fc535e68ec31f7/client/src/fonts/NotoSansKR-Bold.woff
--------------------------------------------------------------------------------
/client/src/fonts/NotoSansKR-Bold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcamp-2020/Project06-A-Slack/27585ed84e3da4655929fbc9b2fc535e68ec31f7/client/src/fonts/NotoSansKR-Bold.woff2
--------------------------------------------------------------------------------
/client/src/fonts/NotoSansKR-Light.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcamp-2020/Project06-A-Slack/27585ed84e3da4655929fbc9b2fc535e68ec31f7/client/src/fonts/NotoSansKR-Light.otf
--------------------------------------------------------------------------------
/client/src/fonts/NotoSansKR-Light.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcamp-2020/Project06-A-Slack/27585ed84e3da4655929fbc9b2fc535e68ec31f7/client/src/fonts/NotoSansKR-Light.woff
--------------------------------------------------------------------------------
/client/src/fonts/NotoSansKR-Light.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcamp-2020/Project06-A-Slack/27585ed84e3da4655929fbc9b2fc535e68ec31f7/client/src/fonts/NotoSansKR-Light.woff2
--------------------------------------------------------------------------------
/client/src/fonts/NotoSansKR-Medium.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcamp-2020/Project06-A-Slack/27585ed84e3da4655929fbc9b2fc535e68ec31f7/client/src/fonts/NotoSansKR-Medium.otf
--------------------------------------------------------------------------------
/client/src/fonts/NotoSansKR-Medium.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcamp-2020/Project06-A-Slack/27585ed84e3da4655929fbc9b2fc535e68ec31f7/client/src/fonts/NotoSansKR-Medium.woff
--------------------------------------------------------------------------------
/client/src/fonts/NotoSansKR-Medium.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcamp-2020/Project06-A-Slack/27585ed84e3da4655929fbc9b2fc535e68ec31f7/client/src/fonts/NotoSansKR-Medium.woff2
--------------------------------------------------------------------------------
/client/src/fonts/NotoSansKR-Regular.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcamp-2020/Project06-A-Slack/27585ed84e3da4655929fbc9b2fc535e68ec31f7/client/src/fonts/NotoSansKR-Regular.otf
--------------------------------------------------------------------------------
/client/src/fonts/NotoSansKR-Regular.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcamp-2020/Project06-A-Slack/27585ed84e3da4655929fbc9b2fc535e68ec31f7/client/src/fonts/NotoSansKR-Regular.woff
--------------------------------------------------------------------------------
/client/src/fonts/NotoSansKR-Regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcamp-2020/Project06-A-Slack/27585ed84e3da4655929fbc9b2fc535e68ec31f7/client/src/fonts/NotoSansKR-Regular.woff2
--------------------------------------------------------------------------------
/client/src/hoc/RestrictRoute.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/require-default-props */
2 | /* eslint-disable react/no-unused-prop-types */
3 | import React, { FC } from 'react';
4 | import { Redirect } from 'react-router-dom';
5 |
6 | interface Props {
7 | path: string | string[];
8 | exact?: boolean;
9 | component: FC;
10 | fallback?: FC;
11 | isAllowed: boolean;
12 | }
13 |
14 | export const RestrictRoute = ({ component: Component, fallback: Fallback, isAllowed }: Props) => {
15 | if (isAllowed) {
16 | return ;
17 | }
18 |
19 | if (Fallback) {
20 | return ;
21 | }
22 |
23 | return ;
24 | };
25 |
--------------------------------------------------------------------------------
/client/src/hoc/index.ts:
--------------------------------------------------------------------------------
1 | export { RestrictRoute } from './RestrictRoute';
2 | export { default as withAuth } from './withAuth';
3 |
--------------------------------------------------------------------------------
/client/src/hoc/withAuth.tsx:
--------------------------------------------------------------------------------
1 | import { useAuthState } from '@/hooks';
2 | import React, { FC } from 'react';
3 | import { Redirect } from 'react-router-dom';
4 |
5 | export default function withAuth(InnerComponent: FC) {
6 | return function WrapperComponent(props: any) {
7 | const { accessToken } = useAuthState();
8 |
9 | if (accessToken) {
10 | // eslint-disable-next-line react/jsx-props-no-spreading
11 | return ;
12 | }
13 |
14 | return ;
15 | };
16 | }
17 |
--------------------------------------------------------------------------------
/client/src/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export * from './useAuthState';
2 | export * from './useUserState';
3 | export * from './useChannelState';
4 | export * from './useOnClickOutside';
5 | export * from './useSignupState';
6 | export * from './useThreadState';
7 | export * from './useSubThreadState';
8 | export * from './useSocketState';
9 | export * from './useEmojiState';
10 | export * from './useFindChannel';
11 | export * from './useRedirectState';
12 | export * from './useInfiniteScroll';
13 | export * from './useDuplicatedChannelState';
14 |
--------------------------------------------------------------------------------
/client/src/hooks/useAuthState.ts:
--------------------------------------------------------------------------------
1 | import { useSelector } from 'react-redux';
2 | import { createSelector } from '@reduxjs/toolkit';
3 | import { RootState } from '@/store/modules';
4 |
5 | const selectAuth = createSelector(
6 | (state: RootState) => state.auth,
7 | (auth) => auth,
8 | );
9 | const useAuthState = () => {
10 | return useSelector(selectAuth);
11 | };
12 |
13 | export { useAuthState };
14 |
--------------------------------------------------------------------------------
/client/src/hooks/useChannelState.ts:
--------------------------------------------------------------------------------
1 | import { useSelector } from 'react-redux';
2 | import { createSelector } from '@reduxjs/toolkit';
3 | import { RootState } from '@/store/modules';
4 |
5 | const selectChannelState = (state: RootState) => state.channel;
6 | const selectChannel = createSelector(selectChannelState, (channels) => channels);
7 |
8 | const selectJoinChannelListState = (state: RootState) => state.channel.myChannelList;
9 | const selectJoinChannelList = (idx: number) =>
10 | createSelector(selectJoinChannelListState, (joinChannelList) => joinChannelList[idx]);
11 |
12 | export const useChannelState = () => {
13 | return useSelector(selectChannel);
14 | };
15 |
16 | export const useJoinChannelListState = (idx: number) => {
17 | return useSelector(selectJoinChannelList(idx));
18 | };
19 |
--------------------------------------------------------------------------------
/client/src/hooks/useDuplicatedChannelState.ts:
--------------------------------------------------------------------------------
1 | import { useSelector } from 'react-redux';
2 | import { createSelector } from '@reduxjs/toolkit';
3 | import { RootState } from '@/store/modules';
4 |
5 | const selectDuplicatedChannel = createSelector(
6 | (state: RootState) => state.duplicatedChannel,
7 | (duplicatedChannel) => duplicatedChannel,
8 | );
9 | const useDuplicatedChannelState = () => {
10 | return useSelector(selectDuplicatedChannel);
11 | };
12 |
13 | export { useDuplicatedChannelState };
14 |
--------------------------------------------------------------------------------
/client/src/hooks/useEmojiState.ts:
--------------------------------------------------------------------------------
1 | import { useSelector } from 'react-redux';
2 | import { createSelector } from '@reduxjs/toolkit';
3 | import { RootState } from '@/store/modules';
4 |
5 | export const selectEmoji = createSelector(
6 | (state: RootState) => state.emoji,
7 | (emoji) => emoji,
8 | );
9 |
10 | const useEmojiState = () => {
11 | return useSelector(selectEmoji);
12 | };
13 |
14 | export { useEmojiState };
15 |
--------------------------------------------------------------------------------
/client/src/hooks/useFindChannel.ts:
--------------------------------------------------------------------------------
1 | import { useSelector } from 'react-redux';
2 | import { createSelector } from '@reduxjs/toolkit';
3 | import { RootState } from '@/store/modules';
4 |
5 | const selectFindChannelState = createSelector(
6 | (state: RootState) => state.findChannel,
7 | (findChannel) => findChannel,
8 | );
9 |
10 | const useFindChannelState = () => {
11 | return useSelector(selectFindChannelState);
12 | };
13 |
14 | export { useFindChannelState };
15 |
--------------------------------------------------------------------------------
/client/src/hooks/useInfiniteScroll.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 |
3 | interface InfiniteScrollProps {
4 | root?: HTMLElement | null;
5 | target: HTMLElement | null;
6 | onIntersect: (a: any) => any;
7 | threshold?: number;
8 | rootMargin?: string;
9 | }
10 |
11 | export const useInfinteScroll = ({
12 | root = null,
13 | target,
14 | onIntersect,
15 | threshold = 1.0,
16 | rootMargin = '0px',
17 | }: InfiniteScrollProps): void => {
18 | useEffect(() => {
19 | const observer = new IntersectionObserver(onIntersect, {
20 | root,
21 | rootMargin,
22 | threshold,
23 | });
24 |
25 | if (!target) {
26 | return;
27 | }
28 |
29 | observer.observe(target);
30 | // eslint-disable-next-line consistent-return
31 | return () => {
32 | observer.unobserve(target);
33 | };
34 | }, [target, root, rootMargin, onIntersect, threshold]);
35 | };
36 |
--------------------------------------------------------------------------------
/client/src/hooks/useOnClickOutside.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 |
3 | const $root = document.getElementById('root') as HTMLElement;
4 | const useOnClickOutside = (ref: React.MutableRefObject, handler: (args: any) => any): void => {
5 | useEffect(() => {
6 | const listener = (e: any) => {
7 | if (!ref.current || ref.current.contains(e.target)) {
8 | return;
9 | }
10 | handler(e);
11 | };
12 |
13 | $root.addEventListener('mousedown', listener);
14 | $root.addEventListener('touchstart', listener);
15 |
16 | return () => {
17 | $root.removeEventListener('mousedown', listener);
18 | $root.removeEventListener('touchstart', listener);
19 | };
20 | }, [ref, handler]);
21 | };
22 |
23 | export { useOnClickOutside };
24 |
--------------------------------------------------------------------------------
/client/src/hooks/useRedirectState.ts:
--------------------------------------------------------------------------------
1 | import { useSelector } from 'react-redux';
2 | import { createSelector } from '@reduxjs/toolkit';
3 | import { RootState } from '@/store/modules';
4 |
5 | const selectRedirectState = createSelector(
6 | (state: RootState) => state.redirect,
7 | (redirect) => redirect,
8 | );
9 | const useRedirectState = () => {
10 | return useSelector(selectRedirectState);
11 | };
12 |
13 | export { useRedirectState };
14 |
--------------------------------------------------------------------------------
/client/src/hooks/useSignupState.ts:
--------------------------------------------------------------------------------
1 | import { useSelector } from 'react-redux';
2 | import { createSelector } from '@reduxjs/toolkit';
3 | import { RootState } from '@/store/modules';
4 |
5 | const selectSignupState = createSelector(
6 | (state: RootState) => state.signup,
7 | (signup) => signup,
8 | );
9 | const useSignupState = () => {
10 | return useSelector(selectSignupState);
11 | };
12 |
13 | export { useSignupState };
14 |
--------------------------------------------------------------------------------
/client/src/hooks/useSocketState.ts:
--------------------------------------------------------------------------------
1 | import { useSelector } from 'react-redux';
2 | import { createSelector } from '@reduxjs/toolkit';
3 | import { RootState } from '@/store/modules';
4 |
5 | const selectSocket = createSelector(
6 | (state: RootState) => state.socket,
7 | (socket) => socket,
8 | );
9 | const useSocketState = () => {
10 | return useSelector(selectSocket);
11 | };
12 |
13 | export { useSocketState };
14 |
--------------------------------------------------------------------------------
/client/src/hooks/useSubThreadState.ts:
--------------------------------------------------------------------------------
1 | import { useSelector } from 'react-redux';
2 | import { createSelector } from '@reduxjs/toolkit';
3 | import { RootState } from '@/store/modules';
4 |
5 | export const selectSubThread = createSelector(
6 | (state: RootState) => state.subThread,
7 | (st) => st,
8 | );
9 |
10 | const useSubThreadState = () => {
11 | return useSelector(selectSubThread);
12 | };
13 |
14 | export { useSubThreadState };
15 |
--------------------------------------------------------------------------------
/client/src/hooks/useThreadState.ts:
--------------------------------------------------------------------------------
1 | import { useSelector } from 'react-redux';
2 | import { createSelector } from '@reduxjs/toolkit';
3 | import { RootState } from '@/store/modules';
4 |
5 | export const selectThread = createSelector(
6 | (state: RootState) => state.thread,
7 | (t) => t,
8 | );
9 |
10 | const useThreadState = () => {
11 | return useSelector(selectThread);
12 | };
13 |
14 | export { useThreadState };
15 |
--------------------------------------------------------------------------------
/client/src/hooks/useUserState.ts:
--------------------------------------------------------------------------------
1 | import { useSelector } from 'react-redux';
2 | import { createSelector } from '@reduxjs/toolkit';
3 | import { RootState } from '@/store/modules';
4 |
5 | const selectUser = createSelector(
6 | (state: RootState) => state.user,
7 | (user) => user,
8 | );
9 | const useUserState = () => {
10 | return useSelector(selectUser);
11 | };
12 |
13 | export { useUserState };
14 |
--------------------------------------------------------------------------------
/client/src/hooks/useViewport.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 |
3 | const useViewport = () => {
4 | const getSize = () => ({
5 | width: window.innerWidth,
6 | height: window.innerHeight,
7 | });
8 |
9 | const [windowSize, setWindowSize] = useState(getSize);
10 |
11 | useEffect(() => {
12 | function handleResize() {
13 | setWindowSize(getSize());
14 | }
15 |
16 | window.addEventListener('resize', handleResize);
17 | return () => window.removeEventListener('resize', handleResize);
18 | }, []);
19 |
20 | return windowSize;
21 | };
22 |
23 | export default useViewport;
24 |
--------------------------------------------------------------------------------
/client/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Slack
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/client/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { Provider } from 'react-redux';
4 | import App from '@/App';
5 | import store from '@/store';
6 |
7 | const rootElement = document.getElementById('root');
8 |
9 | ReactDOM.render(
10 |
11 |
12 | ,
13 | rootElement,
14 | );
15 |
--------------------------------------------------------------------------------
/client/src/pages/EmailVerifyPage.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { EmailBox, CodeVerifyBox } from '@/components';
3 | import { useSignupState } from '@/hooks';
4 |
5 | const EmailVerifyPage: React.FC = () => {
6 | const {
7 | verify: { verifyCode },
8 | } = useSignupState();
9 | return <>{verifyCode ? : }>;
10 | };
11 |
12 | export default EmailVerifyPage;
13 |
--------------------------------------------------------------------------------
/client/src/pages/HomePage.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Redirect } from 'react-router-dom';
3 |
4 | const HomePage: React.FC = () => {
5 | return ;
6 | };
7 |
8 | export default HomePage;
9 |
--------------------------------------------------------------------------------
/client/src/pages/LoadingPage.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { flex } from '@/styles/mixin';
4 | import loadingColorIcon from '@/public/icon/loading-color.svg';
5 |
6 | const Container = styled.div`
7 | width: 100%;
8 | height: 100%;
9 | ${flex()};
10 | `;
11 |
12 | const LoadingBox = styled.div`
13 | width: 100%;
14 | height: 5rem;
15 | flex-shrink: 0;
16 | font-size: 1.7rem;
17 | background-color: white;
18 | ${flex()};
19 | `;
20 |
21 | const LoadingIcon = styled.img`
22 | width: 50px;
23 | height: 50px;
24 | padding-top: 5px;
25 | `;
26 |
27 | const LoadingPage: React.FC = () => {
28 | return (
29 |
30 |
31 | Loading...
32 |
33 |
34 |
35 | );
36 | };
37 |
38 | export default LoadingPage;
39 |
--------------------------------------------------------------------------------
/client/src/pages/LoginPage.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Redirect, useLocation } from 'react-router-dom';
3 | import { useDispatch } from 'react-redux';
4 | import { LoginBox, LogoBox } from '@/components';
5 | import { useAuthState } from '@/hooks';
6 | import { loginSuccess } from '@/store/modules/auth.slice';
7 | import { authService } from '@/services';
8 | import qs from 'qs';
9 |
10 | const LoginPage: React.FC = () => {
11 | const dispatch = useDispatch();
12 |
13 | const handleGoogleOAuthSuccess = async (token: any) => {
14 | const { data, status } = await authService.signupWithGoogleOAuth({
15 | accessToken: token,
16 | });
17 | const { accessToken, refreshToken, user } = data;
18 | if (status === 200) {
19 | localStorage.setItem('accessToken', accessToken);
20 | localStorage.setItem('refreshToken', refreshToken);
21 | localStorage.setItem('userId', user.id);
22 | dispatch(
23 | loginSuccess({ accessToken, refreshToken, userId: user.id ? Number(user.id) : null }),
24 | );
25 | }
26 | };
27 |
28 | const { accessToken } = useAuthState();
29 |
30 | if (accessToken) {
31 | return ;
32 | }
33 |
34 | const { search } = useLocation();
35 | const [, queryString] = search.split('?');
36 | const { accessToken: googleAccessToken } = qs.parse(queryString);
37 |
38 | if (googleAccessToken) {
39 | handleGoogleOAuthSuccess(googleAccessToken);
40 | }
41 |
42 | return (
43 | <>
44 |
45 |
46 | >
47 | );
48 | };
49 |
50 | export default LoginPage;
51 |
--------------------------------------------------------------------------------
/client/src/pages/NotFoundPage.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router-dom';
3 |
4 | const NotFoundPage: React.FC = () => {
5 | return (
6 |
7 |
Home
8 |
404 Not Found
9 |
10 | );
11 | };
12 |
13 | export default NotFoundPage;
14 |
--------------------------------------------------------------------------------
/client/src/pages/SignupPage.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import { useDispatch } from 'react-redux';
3 | import { Redirect } from 'react-router-dom';
4 | import { useSignupState } from '@/hooks';
5 | import { removeVerifyEmail } from '@/store/modules/signup.slice';
6 | import { SignupBox, LogoBox } from '@/components';
7 |
8 | const SignupPage: React.FC = () => {
9 | const dispatch = useDispatch();
10 | const { email } = useSignupState();
11 |
12 | useEffect(() => {
13 | return () => {
14 | dispatch(removeVerifyEmail());
15 | };
16 | }, []);
17 |
18 | return (
19 | <>
20 | {!email ? (
21 |
22 | ) : (
23 | <>
24 |
25 |
26 | >
27 | )}
28 | >
29 | );
30 | };
31 |
32 | export default SignupPage;
33 |
--------------------------------------------------------------------------------
/client/src/pages/WorkSpacePage.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import { useHistory, useParams } from 'react-router-dom';
3 | import { useUserState, useRedirectState } from '@/hooks';
4 | import {
5 | Header,
6 | LeftSideBar,
7 | ThreadListBox,
8 | RightSideBar,
9 | SubThreadListBox,
10 | SubThreadListHeader,
11 | DetailHeader,
12 | DetailBody,
13 | } from '@/components';
14 | import { isExistedChannel, isNumberTypeValue } from '@/utils/utils';
15 | import { socketConnectRequest, socketDisconnectRequest } from '@/store/modules/socket.slice';
16 | import { getEmojiListRequest } from '@/store/modules/emoji.slice';
17 | import { useDispatch } from 'react-redux';
18 | import { setRedirect } from '@/store/modules/redirect.slice';
19 |
20 | import styled from 'styled-components';
21 |
22 | const Container = styled.div`
23 | display: flex;
24 | height: calc(100% - 2.5rem);
25 | `;
26 |
27 | interface RightSideParams {
28 | channelId: string | undefined;
29 | rightSideType: 'detail' | 'user_profile' | 'thread' | undefined;
30 | threadId: string | undefined;
31 | }
32 |
33 | const WorkSpacePage: React.FC = () => {
34 | const { channelId, rightSideType, threadId }: RightSideParams = useParams();
35 | const { userInfo } = useUserState();
36 | const history = useHistory();
37 | const dispatch = useDispatch();
38 | const { url: redirectUrl } = useRedirectState();
39 |
40 | useEffect(() => {
41 | if (redirectUrl) {
42 | history.push(redirectUrl);
43 | dispatch(setRedirect({ url: null }));
44 | }
45 | }, [redirectUrl]);
46 |
47 | /* url에 채널 아이디가 없을 때, 최근에 접속한 채널로 이동 */
48 | useEffect(() => {
49 | if (!channelId && userInfo?.lastChannelId) {
50 | const url = `/client/1/${userInfo.lastChannelId}`;
51 | history.push(url);
52 | }
53 | /* TODO: 권한이 안맞는 채널 & 가입 안된 public으로 이동하는 경우를 처리 */
54 | // if (channelId) {
55 | // if (myChannelList.length !== 0 && !isExistedChannel({ channelId: +channelId, myChannelList })) {
56 | // // redirect 404?
57 | // }
58 | // }
59 | }, [userInfo]);
60 |
61 | useEffect(() => {
62 | dispatch(socketConnectRequest());
63 | dispatch(getEmojiListRequest());
64 | return () => {
65 | dispatch(socketDisconnectRequest());
66 | };
67 | }, []);
68 |
69 | return (
70 | <>
71 |
72 |
73 |
74 |
75 | {rightSideType === 'thread' && channelId && threadId && (
76 | }
79 | body={}
80 | />
81 | )}
82 | {rightSideType === 'detail' && channelId && (
83 | }
86 | body={}
87 | />
88 | )}
89 |
90 | >
91 | );
92 | };
93 |
94 | export default WorkSpacePage;
95 |
--------------------------------------------------------------------------------
/client/src/pages/index.ts:
--------------------------------------------------------------------------------
1 | export { default as NotFoundPage } from './NotFoundPage';
2 | export { default as LoginPage } from './LoginPage';
3 | export { default as HomePage } from './HomePage';
4 | export { default as EmailVerifyPage } from './EmailVerifyPage';
5 | export { default as SignupPage } from './SignupPage';
6 | export { default as WorkSpacePage } from './WorkSpacePage';
7 | export { default as LoadingPage } from './LoadingPage';
8 |
--------------------------------------------------------------------------------
/client/src/public/icon/slack-logo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcamp-2020/Project06-A-Slack/27585ed84e3da4655929fbc9b2fc535e68ec31f7/client/src/public/icon/slack-logo.gif
--------------------------------------------------------------------------------
/client/src/public/icon/slack-logo.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcamp-2020/Project06-A-Slack/27585ed84e3da4655929fbc9b2fc535e68ec31f7/client/src/public/icon/slack-logo.webp
--------------------------------------------------------------------------------
/client/src/public/icon/slack.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcamp-2020/Project06-A-Slack/27585ed84e3da4655929fbc9b2fc535e68ec31f7/client/src/public/icon/slack.ico
--------------------------------------------------------------------------------
/client/src/services/auth.service.ts:
--------------------------------------------------------------------------------
1 | import API from '@/api';
2 | import { Service } from '@/types';
3 | import axios from 'axios';
4 |
5 | interface LoginParam {
6 | email: string;
7 | pw: string;
8 | }
9 |
10 | interface SignupParam {
11 | email: string;
12 | pw: string;
13 | displayName: string;
14 | }
15 |
16 | export const authService: Service = {
17 | login({ email, pw }: LoginParam) {
18 | return API.post('/api/auth/login', { email, pw });
19 | },
20 | logout() {
21 | const refreshToken = localStorage.getItem('refreshToken');
22 | return API.post('/api/auth/logout', { refreshToken });
23 | },
24 | verifyEmail({ email }: { email: string }) {
25 | return API.post('/api/auth/email/verify', { email });
26 | },
27 | signup({ email, pw, displayName }: SignupParam) {
28 | return API.post('/api/auth/signup', { email, pw, displayName });
29 | },
30 | checkExistEmail({ email }: { email: string }) {
31 | return API.post('/api/auth/email/check', { email });
32 | },
33 | signupWithGoogleOAuth({ accessToken }: { accessToken: string }) {
34 | return axios.post('/api/oauth/google/signup', { accessToken });
35 | },
36 | };
37 |
--------------------------------------------------------------------------------
/client/src/services/channel.service.ts:
--------------------------------------------------------------------------------
1 | import API from '@/api';
2 | import { ChannelInfo, Service, User } from '@/types';
3 |
4 | export const channelService: Service = {
5 | getChannels() {
6 | return API.get('/api/channels');
7 | },
8 | getJoinChannels({ userId }: { userId: number }) {
9 | return API.get(`/api/users/${userId}/channels`);
10 | },
11 | getChannel({ channelId }: { channelId: number }) {
12 | return API.get(`/api/channels/${channelId}`);
13 | },
14 | createChannel({ ownerId, name, channelType, isPublic, description, memberCount }: ChannelInfo) {
15 | return API.post('/api/channels', {
16 | ownerId,
17 | name,
18 | channelType,
19 | isPublic,
20 | description,
21 | memberCount,
22 | });
23 | },
24 | joinChannel({ users, channelId }: { users: User[]; channelId: number }) {
25 | return API.post(`/api/channels/${channelId}/invite`, { users });
26 | },
27 | modifyChannelTopic({ channelId, topic }: { channelId: number; topic: string }) {
28 | return API.post(`/api/channels/${channelId}/topic`, { topic });
29 | },
30 | modifyLastChannel({ lastChannelId, userId }: { lastChannelId: number; userId: number }) {
31 | return API.post(`/api/users/${userId}/last-channel`, { lastChannelId });
32 | },
33 | checkDuplicatedChannel({ channelName }: { channelName: string }) {
34 | return API.post('/api/channels/check-duplicated', { channelName });
35 | },
36 | };
37 |
--------------------------------------------------------------------------------
/client/src/services/emoji.service.ts:
--------------------------------------------------------------------------------
1 | import API from '@/api';
2 | import { Service } from '@/types';
3 |
4 | export const emojiService: Service = {
5 | getEmojiList() {
6 | return API.get('/api/emojis');
7 | },
8 | // creatEemoji({ content, userId, channelId, parentId }: createEmojiRequestPayload) {
9 | // return API.post('/api/emojis', { content, userId, channelId, parentId });
10 | // },
11 | };
12 |
--------------------------------------------------------------------------------
/client/src/services/index.ts:
--------------------------------------------------------------------------------
1 | export { authService } from './auth.service';
2 | export { userService } from './user.service';
3 | export { channelService } from './channel.service';
4 | export { emojiService } from './emoji.service';
5 | export { threadService } from './thread.service';
6 | export { subThreadService } from './subThread.service';
7 |
--------------------------------------------------------------------------------
/client/src/services/subThread.service.ts:
--------------------------------------------------------------------------------
1 | import API from '@/api';
2 | import { Service } from '@/types';
3 |
4 | export const subThreadService: Service = {
5 | getSubThreadList({ parentId }: { parentId: number }) {
6 | return API.get(`/api/threads/${parentId}`);
7 | },
8 | };
9 |
--------------------------------------------------------------------------------
/client/src/services/thread.service.ts:
--------------------------------------------------------------------------------
1 | import API from '@/api';
2 | import { getThreadRequestPayload, createThreadRequestPayload } from '@/store/modules/thread.slice';
3 | import { Service } from '@/types';
4 | import qs from 'qs';
5 |
6 | export const threadService: Service = {
7 | getThreadList({ channelId, nextThreadId }: getThreadRequestPayload) {
8 | return API.get(`/api/threads/channels/${channelId}?${qs.stringify({ nextThreadId })}`);
9 | },
10 | createThread({ content, userId, channelId, parentId }: createThreadRequestPayload) {
11 | return API.post('/api/threads', { content, userId, channelId, parentId });
12 | },
13 | };
14 |
--------------------------------------------------------------------------------
/client/src/services/user.service.ts:
--------------------------------------------------------------------------------
1 | import API from '@/api';
2 | import { Service } from '@/types';
3 |
4 | export const userService: Service = {
5 | getUsers() {
6 | return API.get('/api/users');
7 | },
8 | getUser({ id }: { id: number }) {
9 | return API.get(`/api/users/${id}`);
10 | },
11 | editUser({ id, formData }: { id: number; formData: FormData }) {
12 | return API.post(`/api/users/${id}`, formData, {
13 | headers: { 'Content-Type': 'multipart/form-data' },
14 | });
15 | },
16 | searchUsers({
17 | displayName,
18 | channelId,
19 | isDM,
20 | }: {
21 | displayName: string;
22 | channelId: number;
23 | isDM: boolean;
24 | }) {
25 | return API.post('/api/users/search', { displayName, channelId, isDM });
26 | },
27 | getNotJoinedChannels({ userId }: { userId: number }) {
28 | return API.get(`/api/users/${userId}/channels/unsubscribed`);
29 | },
30 | };
31 |
--------------------------------------------------------------------------------
/client/src/store/index.ts:
--------------------------------------------------------------------------------
1 | import { configureStore } from '@reduxjs/toolkit';
2 | import createSagaMiddleware from 'redux-saga';
3 | import rootReducer from '@/store/modules';
4 | import sagas from '@/store/sagas';
5 |
6 | const sagaMiddleware = createSagaMiddleware();
7 |
8 | const store = configureStore({
9 | reducer: rootReducer,
10 | middleware: [sagaMiddleware],
11 | devTools: process.env.MODE === 'dev',
12 | });
13 |
14 | sagaMiddleware.run(sagas);
15 |
16 | export default store;
17 |
--------------------------------------------------------------------------------
/client/src/store/modules/auth.slice.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-empty-function */
2 | /* eslint-disable no-param-reassign */
3 | import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit';
4 | import { RootState } from '@/store/modules';
5 | import { AxiosError } from 'axios';
6 |
7 | interface AuthState {
8 | login: {
9 | loading: boolean;
10 | err: AxiosError | null;
11 | };
12 | accessToken: string | null;
13 | refreshToken: string | null;
14 | userId: number | null;
15 | }
16 |
17 | const authState: AuthState = {
18 | login: {
19 | loading: false,
20 | err: null,
21 | },
22 | accessToken: localStorage.getItem('accessToken'),
23 | refreshToken: localStorage.getItem('refreshToken'),
24 | userId: localStorage.getItem('userId') ? Number(localStorage.getItem('userId')) : null,
25 | };
26 |
27 | export interface LoginRequestPayload {
28 | email: string;
29 | pw: string;
30 | }
31 | export interface LoginSuccessPayload {
32 | accessToken: string;
33 | refreshToken: string;
34 | userId: number | null;
35 | }
36 |
37 | const authSlice = createSlice({
38 | name: 'auth',
39 | initialState: authState,
40 | reducers: {
41 | loginRequest(state, action: PayloadAction) {
42 | state.login.loading = true;
43 | },
44 | loginSuccess(state, { payload }: PayloadAction) {
45 | const { accessToken, refreshToken, userId } = payload;
46 | state.accessToken = accessToken;
47 | state.refreshToken = refreshToken;
48 | state.userId = userId;
49 |
50 | state.login.loading = false;
51 | state.login.err = null;
52 | },
53 | loginFailure(state, { payload }: PayloadAction<{ err: AxiosError }>) {
54 | state.login.loading = false;
55 | state.login.err = payload.err;
56 | },
57 | logoutRequest() {},
58 | logoutSuccess(state) {
59 | state.accessToken = null;
60 | state.refreshToken = null;
61 | state.userId = null;
62 |
63 | state.login.loading = false;
64 | state.login.err = null;
65 | },
66 | logoutFailure() {},
67 | },
68 | });
69 |
70 | const selectAuthState = (state: RootState) => state.auth;
71 |
72 | export const selectAuth = createSelector(selectAuthState, (auth) => auth);
73 | export const AUTH = authSlice.name;
74 | export const {
75 | loginRequest,
76 | loginSuccess,
77 | loginFailure,
78 | logoutRequest,
79 | logoutSuccess,
80 | logoutFailure,
81 | } = authSlice.actions;
82 |
83 | export default authSlice.reducer;
84 |
--------------------------------------------------------------------------------
/client/src/store/modules/duplicatedChannel.slice.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-empty-function */
2 | /* eslint-disable no-param-reassign */
3 | import { createSlice, PayloadAction } from '@reduxjs/toolkit';
4 |
5 | interface DuplicatedChannelState {
6 | loading: boolean;
7 | isDuplicated: boolean;
8 | err: Error | null;
9 | }
10 |
11 | const initialState: DuplicatedChannelState = {
12 | loading: false,
13 | isDuplicated: false,
14 | err: null,
15 | };
16 |
17 | const duplicatedChannelSlice = createSlice({
18 | name: 'duplicatedChannel',
19 | initialState,
20 | reducers: {
21 | checkDuplicateRequest(state, { payload }: PayloadAction<{ channelName: string }>) {
22 | return { ...initialState, loading: true };
23 | },
24 | checkDuplicateSuccess(state, { payload }: PayloadAction<{ isDuplicated: boolean }>) {
25 | state.loading = false;
26 | state.isDuplicated = payload.isDuplicated;
27 | },
28 | checkDuplicateFailure(state, { payload }: PayloadAction<{ err: Error }>) {
29 | state.loading = false;
30 | state.err = payload.err;
31 | },
32 | resetDuplicateState(state) {
33 | state.isDuplicated = false;
34 | state.loading = false;
35 | state.err = null;
36 | },
37 | },
38 | });
39 |
40 | export const DUPLICATED_CHANNEL = duplicatedChannelSlice.name;
41 | export const {
42 | checkDuplicateRequest,
43 | checkDuplicateSuccess,
44 | checkDuplicateFailure,
45 | resetDuplicateState,
46 | } = duplicatedChannelSlice.actions;
47 | export default duplicatedChannelSlice.reducer;
48 |
--------------------------------------------------------------------------------
/client/src/store/modules/emoji.slice.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-empty-function */
2 | /* eslint-disable no-param-reassign */
3 | import { createSlice, PayloadAction } from '@reduxjs/toolkit';
4 |
5 | interface Emoji {
6 | id: number;
7 | name: string;
8 | url: string;
9 | }
10 |
11 | interface EmojiState {
12 | emojiList: Emoji[] | null;
13 | }
14 |
15 | const emojiState: EmojiState = {
16 | emojiList: [],
17 | };
18 |
19 | const emojiSlice = createSlice({
20 | name: 'emoji',
21 | initialState: emojiState,
22 | reducers: {
23 | getEmojiListRequest() {},
24 | getEmojiListSuccess(state, action: PayloadAction) {
25 | state.emojiList = action.payload.emojiList;
26 | },
27 | getEmojiListFailure(state, action) {},
28 | createEmojiRequest(state, action) {},
29 | createEmojiSuccess(state, action) {},
30 | createEmojiFailure(state, action) {},
31 | },
32 | });
33 |
34 | export const EMOJI = emojiSlice.name;
35 | export const {
36 | getEmojiListRequest,
37 | getEmojiListSuccess,
38 | getEmojiListFailure,
39 | createEmojiRequest,
40 | createEmojiSuccess,
41 | createEmojiFailure,
42 | } = emojiSlice.actions;
43 |
44 | export default emojiSlice.reducer;
45 |
--------------------------------------------------------------------------------
/client/src/store/modules/findChannel.slice.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-empty-function */
2 | /* eslint-disable no-param-reassign */
3 | import { createSlice, PayloadAction } from '@reduxjs/toolkit';
4 | import { Channel } from '@/types';
5 |
6 | interface FindChannelState {
7 | notJoinedChannelList: Channel[];
8 | }
9 |
10 | const initialState: FindChannelState = {
11 | notJoinedChannelList: [],
12 | };
13 |
14 | export interface FindNotJoinedChannelListRequestPayload {
15 | userId: number;
16 | }
17 |
18 | const findChannelSlice = createSlice({
19 | name: 'findChannel',
20 | initialState,
21 | reducers: {
22 | loadNotJoinedChannelsRequest(
23 | state,
24 | action: PayloadAction,
25 | ) {},
26 | loadNotJoinedChannelsSuccess(
27 | state,
28 | { payload }: PayloadAction<{ notJoinedChannelList: Channel[] }>,
29 | ) {
30 | state.notJoinedChannelList = payload.notJoinedChannelList;
31 | },
32 | loadNotJoinedChannelsFailure(state, action: PayloadAction<{ err: Error }>) {
33 | // todo 에러처리
34 | },
35 | },
36 | });
37 |
38 | export const FIND_CHANNEL = findChannelSlice.name;
39 | export const {
40 | loadNotJoinedChannelsRequest,
41 | loadNotJoinedChannelsSuccess,
42 | loadNotJoinedChannelsFailure,
43 | } = findChannelSlice.actions;
44 | export default findChannelSlice.reducer;
45 |
--------------------------------------------------------------------------------
/client/src/store/modules/index.ts:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 | import channelSlice, { CHANNEL } from './channel.slice';
3 | import authSlice, { AUTH } from './auth.slice';
4 | import threadSlice, { THREAD } from './thread.slice';
5 | import subThreadSlice, { SUBTHREAD } from './subThread.slice';
6 | import userSlice, { USER } from './user.slice';
7 | import signupSlice, { SIGNUP } from './signup.slice';
8 | import socketSlice, { SOCKET } from './socket.slice';
9 | import emojiSlice, { EMOJI } from './emoji.slice';
10 | import findChannelSlice, { FIND_CHANNEL } from './findChannel.slice';
11 | import redirectSlice, { REDIRECT } from './redirect.slice';
12 | import duplicatedSlice, { DUPLICATED_CHANNEL } from './duplicatedChannel.slice';
13 |
14 | const rootReducer = combineReducers({
15 | [CHANNEL]: channelSlice,
16 | [AUTH]: authSlice,
17 | [THREAD]: threadSlice,
18 | [SUBTHREAD]: subThreadSlice,
19 | [USER]: userSlice,
20 | [SIGNUP]: signupSlice,
21 | [SOCKET]: socketSlice,
22 | [EMOJI]: emojiSlice,
23 | [FIND_CHANNEL]: findChannelSlice,
24 | [REDIRECT]: redirectSlice,
25 | [DUPLICATED_CHANNEL]: duplicatedSlice,
26 | });
27 |
28 | export type RootState = ReturnType;
29 |
30 | export default rootReducer;
31 |
--------------------------------------------------------------------------------
/client/src/store/modules/redirect.slice.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-empty-function */
2 | /* eslint-disable no-param-reassign */
3 | import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit';
4 | import { RootState } from '@/store/modules';
5 |
6 | interface RedirectState {
7 | url: string | null;
8 | }
9 |
10 | const redirectState: RedirectState = {
11 | url: null,
12 | };
13 |
14 | const redirectSlice = createSlice({
15 | name: 'redirect',
16 | initialState: redirectState,
17 | reducers: {
18 | setRedirect(state, { payload }: PayloadAction<{ url: string | null }>) {
19 | state.url = payload.url;
20 | },
21 | },
22 | });
23 |
24 | const selectRedirectState = (state: RootState) => state.redirect;
25 |
26 | export const selectRedirect = createSelector(selectRedirectState, (redirect) => redirect);
27 | export const REDIRECT = redirectSlice.name;
28 | export const { setRedirect } = redirectSlice.actions;
29 |
30 | export default redirectSlice.reducer;
31 |
--------------------------------------------------------------------------------
/client/src/store/modules/socket.slice.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-empty-function */
2 | /* eslint-disable no-param-reassign */
3 | import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit';
4 | import { RootState } from '@/store/modules';
5 | import { Socket } from '@/store/sagas/socketSaga';
6 | import { RoomEvent, SocketEvent } from '@/types';
7 |
8 | interface SocketState {
9 | socket: Socket | null;
10 | }
11 |
12 | const socketState: SocketState = {
13 | socket: null,
14 | };
15 |
16 | const socketSlice = createSlice({
17 | name: 'socket',
18 | initialState: socketState,
19 | reducers: {
20 | socketConnectRequest() {},
21 | socketConnectSuccess(state, { payload }: PayloadAction<{ socket: Socket }>) {
22 | state.socket = payload.socket;
23 | },
24 | socketConnectFailure(state, { payload }: PayloadAction<{ err: Error }>) {
25 | console.error('socket connection failure', payload.err);
26 | },
27 | sendMessageRequest(state, { payload }: PayloadAction) {},
28 | socketDisconnectRequest() {},
29 | enterRoomRequest(state, { payload }: PayloadAction) {},
30 | leaveRoomRequest(state, { payload }: PayloadAction) {},
31 | },
32 | });
33 |
34 | const selectsocketState = (state: RootState) => state.socket;
35 |
36 | export const selectsocket = createSelector(selectsocketState, (socket) => socket);
37 | export const SOCKET = socketSlice.name;
38 | export const {
39 | socketConnectRequest,
40 | socketConnectSuccess,
41 | socketConnectFailure,
42 | sendMessageRequest,
43 | socketDisconnectRequest,
44 | enterRoomRequest,
45 | leaveRoomRequest,
46 | } = socketSlice.actions;
47 |
48 | export default socketSlice.reducer;
49 |
--------------------------------------------------------------------------------
/client/src/store/sagas/authSaga.ts:
--------------------------------------------------------------------------------
1 | import { put, all, call, take, fork, cancel } from 'redux-saga/effects';
2 | import {
3 | loginRequest,
4 | loginSuccess,
5 | loginFailure,
6 | logoutRequest,
7 | logoutSuccess,
8 | logoutFailure,
9 | LoginRequestPayload,
10 | } from '@/store/modules/auth.slice';
11 | import { resetUserState } from '@/store/modules/user.slice';
12 | import { resetChannelState } from '@/store/modules/channel.slice';
13 | import { resetThreadState } from '@/store/modules/thread.slice';
14 | import { resetSubThreadState } from '@/store/modules/subThread.slice';
15 | import { authService } from '@/services';
16 |
17 | function* login({ email, pw }: LoginRequestPayload) {
18 | try {
19 | const { data, status } = yield call(authService.login, { email, pw });
20 | const { accessToken, refreshToken, user } = data;
21 | if (status === 200) {
22 | localStorage.setItem('accessToken', accessToken);
23 | localStorage.setItem('refreshToken', refreshToken);
24 | localStorage.setItem('userId', user.id);
25 | yield put(
26 | loginSuccess({ accessToken, refreshToken, userId: user.id ? Number(user.id) : null }),
27 | );
28 | }
29 | } catch (err) {
30 | yield put(loginFailure({ err }));
31 | }
32 | }
33 |
34 | function* logout() {
35 | try {
36 | const { status } = yield call(authService.logout);
37 | if (status === 200) {
38 | localStorage.removeItem('accessToken');
39 | localStorage.removeItem('refreshToken');
40 | localStorage.removeItem('userId');
41 | yield put(logoutSuccess());
42 | yield all([
43 | put(resetUserState()),
44 | put(resetChannelState()),
45 | put(resetThreadState()),
46 | put(resetSubThreadState()),
47 | ]);
48 | }
49 | } catch (err) {
50 | yield put(logoutFailure());
51 | }
52 | }
53 |
54 | function* loginFlow() {
55 | while (true) {
56 | const {
57 | payload: { email, pw },
58 | } = yield take(loginRequest);
59 |
60 | const loginTask = yield fork(login, { email, pw });
61 | const action = yield take([logoutRequest, loginFailure]);
62 |
63 | if (action.type === logoutRequest().type) {
64 | yield cancel(loginTask);
65 | }
66 | }
67 | }
68 |
69 | function* logoutFlow() {
70 | while (true) {
71 | yield take(logoutRequest);
72 | yield logout();
73 | }
74 | }
75 |
76 | export default function* authSaga() {
77 | yield all([fork(loginFlow), fork(logoutFlow)]);
78 | }
79 |
--------------------------------------------------------------------------------
/client/src/store/sagas/duplicatedChannelSaga.ts:
--------------------------------------------------------------------------------
1 | import { all, fork, put, call, debounce } from 'redux-saga/effects';
2 | import {
3 | checkDuplicateRequest,
4 | checkDuplicateSuccess,
5 | checkDuplicateFailure,
6 | } from '@/store/modules/duplicatedChannel.slice';
7 | import { PayloadAction } from '@reduxjs/toolkit';
8 | import { channelService } from '@/services';
9 |
10 | function* checkDuplicatedChannel({ payload }: PayloadAction<{ channelName: string }>) {
11 | const { channelName } = payload;
12 | try {
13 | const { data, status } = yield call(channelService.checkDuplicatedChannel, { channelName });
14 | if (status === 200) {
15 | const { isDuplicated } = data;
16 | yield put(checkDuplicateSuccess({ isDuplicated }));
17 | }
18 | } catch (err) {
19 | yield put(checkDuplicateFailure({ err }));
20 | }
21 | }
22 |
23 | function* watchCheckDuplicatedChannel() {
24 | yield debounce(200, checkDuplicateRequest, checkDuplicatedChannel);
25 | }
26 |
27 | export default function* duplicatedChannelSaga() {
28 | yield all([fork(watchCheckDuplicatedChannel)]);
29 | }
30 |
--------------------------------------------------------------------------------
/client/src/store/sagas/emojiSaga.ts:
--------------------------------------------------------------------------------
1 | import { all, fork, take, call, takeEvery, put } from 'redux-saga/effects';
2 | import {
3 | getEmojiListRequest,
4 | getEmojiListSuccess,
5 | getEmojiListFailure,
6 | createEmojiRequest,
7 | createEmojiSuccess,
8 | createEmojiFailure,
9 | } from '@/store/modules/emoji.slice';
10 | import { emojiService } from '@/services/emoji.service';
11 |
12 | function* getEmojiList() {
13 | try {
14 | const { data, status } = yield call(emojiService.getEmojiList);
15 | if (status === 200) {
16 | yield put(getEmojiListSuccess({ emojiList: data.emojiList }));
17 | }
18 | } catch (err) {
19 | yield put(getEmojiListFailure(err));
20 | }
21 | }
22 |
23 | function* watchGetEmojiList() {
24 | yield takeEvery(getEmojiListRequest, getEmojiList);
25 | }
26 |
27 | export default function* EmojiSaga() {
28 | yield all([fork(watchGetEmojiList)]);
29 | }
30 |
--------------------------------------------------------------------------------
/client/src/store/sagas/findChannelSaga.ts:
--------------------------------------------------------------------------------
1 | import { put, all, call, fork, takeEvery } from 'redux-saga/effects';
2 | import { PayloadAction } from '@reduxjs/toolkit';
3 | import {
4 | loadNotJoinedChannelsRequest,
5 | loadNotJoinedChannelsSuccess,
6 | loadNotJoinedChannelsFailure,
7 | FindNotJoinedChannelListRequestPayload,
8 | } from '@/store/modules/findChannel.slice';
9 | import { userService } from '@/services';
10 |
11 | function* notJoinedChannels({
12 | payload: { userId },
13 | }: PayloadAction) {
14 | try {
15 | const { data, status } = yield call(userService.getNotJoinedChannels, { userId });
16 | if (status === 200) {
17 | yield put(loadNotJoinedChannelsSuccess({ notJoinedChannelList: data.notJoinedChannelList }));
18 | }
19 | } catch (err) {
20 | loadNotJoinedChannelsFailure({ err });
21 | }
22 | }
23 |
24 | function* watchNotJoinedChannels() {
25 | yield takeEvery(loadNotJoinedChannelsRequest, notJoinedChannels);
26 | }
27 |
28 | export default function* findChannelSaga() {
29 | yield all([fork(watchNotJoinedChannels)]);
30 | }
31 |
--------------------------------------------------------------------------------
/client/src/store/sagas/index.ts:
--------------------------------------------------------------------------------
1 | import { all, fork } from 'redux-saga/effects';
2 | import authSaga from './authSaga';
3 | import channelSaga from './channelSaga';
4 | import threadSaga from './threadSaga';
5 | import userSaga from './userSaga';
6 | import signupSage from './signupSaga';
7 | import subThreadSaga from './subThreadSaga';
8 | import socketSaga from './socketSaga';
9 | import emojiSaga from './emojiSaga';
10 | import findChannelSaga from './findChannelSaga';
11 | import duplicatedChannelSaga from './duplicatedChannelSaga';
12 |
13 | export default function* rootSaga() {
14 | yield all([
15 | fork(authSaga),
16 | fork(channelSaga),
17 | fork(threadSaga),
18 | fork(userSaga),
19 | fork(signupSage),
20 | fork(subThreadSaga),
21 | fork(socketSaga),
22 | fork(emojiSaga),
23 | fork(findChannelSaga),
24 | fork(duplicatedChannelSaga),
25 | ]);
26 | }
27 |
--------------------------------------------------------------------------------
/client/src/store/sagas/signupSaga.ts:
--------------------------------------------------------------------------------
1 | import { put, all, call, fork, delay, takeLatest, throttle, debounce } from 'redux-saga/effects';
2 | import { PayloadAction } from '@reduxjs/toolkit';
3 | import {
4 | verifyEmailSendRequest,
5 | verifyEmailSendSuccess,
6 | verifyEmailSendFailure,
7 | removeVerifyCode,
8 | VerifyEmailSendRequestPayload,
9 | signupRequestPayload,
10 | signupRequest,
11 | signupSuccess,
12 | signupFailure,
13 | checkExistEmailRequest,
14 | checkExistEmailSuccess,
15 | checkExistEmailFailure,
16 | } from '@/store/modules/signup.slice';
17 | import { authService } from '@/services';
18 | import { TIME_MILLIS } from '@/utils/constants';
19 |
20 | function* verifyEmailSendFlow({
21 | payload: { email },
22 | }: PayloadAction) {
23 | try {
24 | const {
25 | data: { verifyCode },
26 | status,
27 | } = yield call(authService.verifyEmail, { email });
28 | if (status === 200) {
29 | yield put(verifyEmailSendSuccess({ verifyCode, email }));
30 | yield delay(TIME_MILLIS.FIVE_MINUTE);
31 | yield put(removeVerifyCode());
32 | }
33 | } catch (err) {
34 | verifyEmailSendFailure({ err });
35 | }
36 | }
37 |
38 | function* watchVerifyFlow() {
39 | yield takeLatest(verifyEmailSendRequest, verifyEmailSendFlow);
40 | }
41 |
42 | function* signupFlow({ payload }: PayloadAction) {
43 | const { email, pw, displayName } = payload;
44 | try {
45 | const { status } = yield call(authService.signup, { email, pw, displayName });
46 | if (status === 200) {
47 | yield put(signupSuccess({ status }));
48 | }
49 | } catch (err) {
50 | yield signupFailure({ err });
51 | }
52 | }
53 |
54 | function* watchSignupFlow() {
55 | yield takeLatest(signupRequest, signupFlow);
56 | }
57 |
58 | function* checkExistEmailFlow({ payload }: PayloadAction<{ email: string }>) {
59 | const { email } = payload;
60 | try {
61 | const { status } = yield call(authService.checkExistEmail, { email });
62 | if (status === 200) {
63 | yield put(checkExistEmailSuccess({ status }));
64 | }
65 | } catch (err) {
66 | yield put(checkExistEmailFailure({ err }));
67 | }
68 | }
69 |
70 | function* debounceCheckExistEmailFlow() {
71 | yield debounce(300, checkExistEmailRequest, checkExistEmailFlow);
72 | }
73 |
74 | export default function* signupSaga() {
75 | yield all([fork(watchVerifyFlow), fork(watchSignupFlow), fork(debounceCheckExistEmailFlow)]);
76 | }
77 |
--------------------------------------------------------------------------------
/client/src/store/sagas/subThreadSaga.ts:
--------------------------------------------------------------------------------
1 | import { all, fork, call, put, take } from 'redux-saga/effects';
2 | import {
3 | getSubThreadRequest,
4 | getSubThreadSuccess,
5 | getSubThreadFailure,
6 | GetSubThreadRequestPayload,
7 | } from '@/store/modules/subThread.slice';
8 | import { subThreadService } from '@/services/subThread.service';
9 |
10 | function* getSubThreadList({ parentId }: GetSubThreadRequestPayload) {
11 | try {
12 | const { data, status } = yield call(subThreadService.getSubThreadList, { parentId });
13 | if (status === 200) {
14 | const [parentThreadData] = data.parentThread;
15 |
16 | yield put(
17 | getSubThreadSuccess({
18 | parentThread: parentThreadData,
19 | subThreadList: data.subThreadList,
20 | }),
21 | );
22 | }
23 | } catch (err) {
24 | yield put(getSubThreadFailure(err));
25 | }
26 | }
27 |
28 | function* watchGetSubThreadList() {
29 | while (true) {
30 | const {
31 | payload: { parentId },
32 | } = yield take(getSubThreadRequest);
33 |
34 | yield fork(getSubThreadList, { parentId });
35 | }
36 | }
37 |
38 | export default function* subThreadSaga() {
39 | yield all([fork(watchGetSubThreadList)]);
40 | }
41 |
--------------------------------------------------------------------------------
/client/src/store/sagas/threadSaga.ts:
--------------------------------------------------------------------------------
1 | import { all, fork, take, call, takeEvery, put } from 'redux-saga/effects';
2 | import {
3 | getThreadRequest,
4 | getThreadSuccess,
5 | getThreadFailure,
6 | createThreadRequest,
7 | createThreadSuccess,
8 | createThreadFailure,
9 | getThreadRequestPayload,
10 | createThreadRequestPayload,
11 | addThreadListRequest,
12 | addThreadListSuccess,
13 | addThreadListFailure,
14 | addThreadRequestPayload,
15 | } from '@/store/modules/thread.slice';
16 | import { threadService } from '@/services/thread.service';
17 | import { PayloadAction } from '@reduxjs/toolkit';
18 |
19 | function* getThreadList({ payload }: PayloadAction) {
20 | const { channelId } = payload;
21 | try {
22 | const { data, status } = yield call(threadService.getThreadList, { channelId });
23 | const { threadList, nextThreadId } = data;
24 | if (status === 200) {
25 | yield put(getThreadSuccess({ threadList, canScrollToBottom: true, nextThreadId }));
26 | }
27 | } catch (err) {
28 | yield put(getThreadFailure(err));
29 | }
30 | }
31 |
32 | function* watchGetThreadList() {
33 | yield takeEvery(getThreadRequest, getThreadList);
34 | }
35 |
36 | function* createThread({ content, userId, channelId, parentId }: createThreadRequestPayload) {
37 | try {
38 | const result = yield call(threadService.createThread, {
39 | content,
40 | userId,
41 | channelId,
42 | parentId,
43 | });
44 | yield put(createThreadSuccess({ result: result.data.result }));
45 | } catch (err) {
46 | yield put(createThreadFailure(err));
47 | }
48 | }
49 |
50 | function* watchcreateThread() {
51 | while (true) {
52 | const {
53 | payload: { content, userId, channelId, parentId },
54 | } = yield take(createThreadRequest);
55 | yield fork(createThread, { content, userId, channelId, parentId });
56 | }
57 | }
58 |
59 | function* addThreadList({ payload }: PayloadAction) {
60 | const { channelId, nextThreadId: nid } = payload;
61 | try {
62 | const { data, status } = yield call(threadService.getThreadList, {
63 | channelId,
64 | nextThreadId: nid,
65 | });
66 | const { threadList, nextThreadId } = data;
67 | if (status === 200) {
68 | yield put(addThreadListSuccess({ threadList, nextThreadId }));
69 | }
70 | } catch (err) {
71 | yield put(addThreadListFailure());
72 | }
73 | }
74 |
75 | function* watchAddThreadList() {
76 | yield takeEvery(addThreadListRequest, addThreadList);
77 | }
78 |
79 | export default function* threadSaga() {
80 | yield all([fork(watchGetThreadList), fork(watchcreateThread), fork(watchAddThreadList)]);
81 | }
82 |
--------------------------------------------------------------------------------
/client/src/styles/index.ts:
--------------------------------------------------------------------------------
1 | import { css, createGlobalStyle } from 'styled-components';
2 | import { normalize } from 'styled-normalize';
3 |
4 | import notoSansLightWoff from '@/fonts/NotoSansKR-Light.woff';
5 | import notoSansRegularWoff from '@/fonts/NotoSansKR-Regular.woff';
6 | import notoSansMediumWoff from '@/fonts/NotoSansKR-Medium.woff';
7 | import notoSansBoldWoff from '@/fonts/NotoSansKR-Bold.woff';
8 |
9 | const commonStyle = css`
10 | html {
11 | height: 100%;
12 | }
13 |
14 | body {
15 | position: relative;
16 | height: 100%;
17 | }
18 |
19 | #root {
20 | position: relative;
21 | height: 100%;
22 | display: flex;
23 | flex: 1;
24 | flex-direction: column;
25 | }
26 |
27 | *,
28 | *:before,
29 | *:after {
30 | box-sizing: border-box;
31 | }
32 |
33 | input,
34 | button {
35 | font-size: 1rem;
36 | }
37 |
38 | a {
39 | text-decoration: none;
40 | color: inherit;
41 | &:link {
42 | text-decoration: none;
43 | }
44 | &:visited {
45 | text-decoration: none;
46 | }
47 | &:hover {
48 | text-decoration: none;
49 | }
50 | }
51 |
52 | ::-webkit-scrollbar {
53 | width: 8px;
54 | }
55 | ::-webkit-scrollbar-track {
56 | background-color: transparent;
57 | }
58 | ::-webkit-scrollbar-thumb {
59 | background: #848484;
60 | border-radius: 5px;
61 | }
62 | ::-webkit-scrollbar-thumb:hover {
63 | }
64 | ::-webkit-scrollbar-thumb:active {
65 | }
66 | ::-webkit-scrollbar-button {
67 | display: none;
68 | }
69 | `;
70 |
71 | const fontFace = css`
72 | @font-face {
73 | font-family: 'noto sans';
74 | font-style: normal;
75 | font-weight: 200;
76 | src: url(${notoSansLightWoff}) format('woff');
77 | font-display: swap;
78 | }
79 | @font-face {
80 | font-family: 'noto sans';
81 | font-style: normal;
82 | font-weight: 400;
83 | src: url(${notoSansRegularWoff}) format('woff');
84 | font-display: swap;
85 | }
86 | @font-face {
87 | font-family: 'noto sans';
88 | font-style: normal;
89 | font-weight: 700;
90 | src: url(${notoSansMediumWoff}) format('woff');
91 | font-display: swap;
92 | }
93 | @font-face {
94 | font-family: 'noto sans';
95 | font-style: normal;
96 | font-weight: 800;
97 | src: url(${notoSansBoldWoff}) format('woff');
98 | font-display: swap;
99 | }
100 |
101 | * {
102 | font-family: 'noto sans', 'Noto Sans KR', '맑은 고딕', 'MalgunGothic', sans-serif;
103 | }
104 | `;
105 |
106 | export const GlobalStyle = createGlobalStyle`
107 | ${normalize};
108 | ${commonStyle};
109 | ${fontFace};
110 | `;
111 |
--------------------------------------------------------------------------------
/client/src/styles/mixin.ts:
--------------------------------------------------------------------------------
1 | import styled, { css } from 'styled-components';
2 |
3 | export const flex = (alignItems?: string, justifyContent?: string, flexDirection?: string) => css`
4 | align-items: ${alignItems ?? 'center'};
5 | justify-content: ${justifyContent ?? 'center'};
6 | display: flex;
7 | flex-direction: ${flexDirection ?? 'row'};
8 | `;
9 |
10 | export const focusedInputBoxShadow = css`
11 | box-shadow: 0 0 10px rgba(18, 100, 163, 0.3);
12 | `;
13 |
14 | export const modalBoxShadow = css`
15 | box-shadow: 0px 0px 4px rgba(0, 0, 0, 0.3);
16 | `;
17 |
18 | export const hoverActive = css`
19 | cursor: pointer;
20 | &:hover,
21 | &:active {
22 | background-color: ${(props) => props.theme.color.gray5};
23 | path {
24 | fill: ${(props) => props.theme.color.black4};
25 | }
26 | }
27 | &:active {
28 | background-color: ${(props) => props.theme.color.gray4};
29 | }
30 | `;
31 |
32 | // 주의!! 이 mixin을 쓰면 position에 relative가 들어감!!
33 | export const hoverUnderline = (color: string, bottom?: string) => css`
34 | position: relative;
35 | &:hover:after {
36 | content: '';
37 | display: block;
38 | width: 100%;
39 | height: 0.5px;
40 | position: absolute;
41 | bottom: ${bottom ?? '-1.5px'};
42 | background-color: ${(props) => color ?? props.theme.color.black6};
43 | }
44 | `;
45 |
--------------------------------------------------------------------------------
/client/src/styles/shared/button.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { darken, lighten } from 'polished';
3 | import { flex } from '@/styles/mixin';
4 |
5 | export const Button = styled.button`
6 | ${flex()};
7 | height: 2.2rem;
8 | padding: 0 1rem;
9 | font-size: 0.9rem;
10 | font-weight: bold;
11 | border-radius: 5px;
12 | border: none;
13 | cursor: pointer;
14 | `;
15 |
16 | export const SubmitButton = styled(Button)`
17 | color: white;
18 | background-color: ${(props) => props.theme.color.green1};
19 | border: 1px solid ${(props) => lighten(0.03, props.theme.color.green1)};
20 | &:hover:not([disabled]) {
21 | background-color: ${(props) => lighten(0.03, props.theme.color.green1)};
22 | }
23 | &:active:not([disabled]) {
24 | background-color: ${(props) => lighten(0.06, props.theme.color.green1)};
25 | }
26 | &:disabled {
27 | cursor: wait;
28 | }
29 | `;
30 |
31 | export const CancelButton = styled(Button)`
32 | color: ${(props) => props.theme.color.black3};
33 | background-color: ${(props) => props.theme.color.semiWhite};
34 | border: 1px solid ${(props) => props.theme.color.lightGray1};
35 | &:hover:not([disabled]) {
36 | background-color: ${(props) => darken(0.02, props.theme.color.semiWhite)};
37 | }
38 | &:active:not([disabled]) {
39 | background-color: ${(props) => darken(0.04, props.theme.color.semiWhite)};
40 | }
41 | &:disabled {
42 | cursor: wait;
43 | }
44 | `;
45 |
--------------------------------------------------------------------------------
/client/src/styles/shared/form.ts:
--------------------------------------------------------------------------------
1 | import { lighten } from 'polished';
2 | import styled from 'styled-components';
3 |
4 | export const FormLabel = styled.label`
5 | margin-top: ${(props) => props.theme.size.xxs};
6 | font-size: ${(props) => props.theme.size.s};
7 | font-weight: bold;
8 | `;
9 |
10 | export const FormInput = styled.input`
11 | display: block;
12 | width: 25rem;
13 | height: 3rem;
14 | margin: ${(props) => props.theme.size.xxs} 0;
15 | padding: ${(props) => props.theme.size.xs} 0;
16 | padding-left: ${(props) => props.theme.size.xxxs};
17 | border: 1px solid ${(props) => props.theme.color.black9};
18 | border-radius: 5px;
19 | outline: 0;
20 | &:focus {
21 | transition: 0.3s;
22 | box-shadow: ${(props) => props.theme.boxShadow.skyblue};
23 | }
24 | `;
25 |
26 | export const FormButton = styled.button`
27 | width: 25rem;
28 | margin: ${(props) => props.theme.size.m} 0;
29 | padding: ${(props) => props.theme.size.m} 0;
30 | font-size: ${(props) => props.theme.size.m};
31 | font-weight: bold;
32 | color: white;
33 | border: 0;
34 | border-radius: 5px;
35 | background-color: ${(props) => props.theme.color.main};
36 | cursor: pointer;
37 | &:hover {
38 | transition: 0.3s;
39 | background-color: ${(props) => lighten(0.1, props.theme.color.main)};
40 | }
41 | `;
42 |
--------------------------------------------------------------------------------
/client/src/styles/shared/index.ts:
--------------------------------------------------------------------------------
1 | export * from './form';
2 | export * from './button';
3 |
--------------------------------------------------------------------------------
/client/src/styles/styled.d.ts:
--------------------------------------------------------------------------------
1 | import 'styled-components';
2 |
3 | declare module 'styled-components' {
4 | export interface DefaultTheme {
5 | boxShadow: {
6 | skyblue: string;
7 | darkgray: string;
8 | };
9 |
10 | color: {
11 | main: string;
12 | lightBlack: string;
13 | black1: string;
14 | black2: string;
15 | black3: string;
16 | black4: string;
17 | black5: string;
18 | black6: string;
19 | black7: string;
20 | black8: string;
21 | black9: string;
22 |
23 | gray1: string;
24 | gray2: string;
25 | gray3: string;
26 | gray4: string;
27 | gray5: string;
28 | gray6: string;
29 |
30 | lightGray1: string;
31 | lightGray2: string;
32 | lightGray3: string;
33 |
34 | purple1: string;
35 | purple2: string;
36 | purple3: string;
37 |
38 | white: string;
39 | semiWhite: string;
40 | modalWhite: string;
41 |
42 | green1: string;
43 | green2: string;
44 |
45 | blue1: string;
46 | blue2: string;
47 | blue3: string;
48 |
49 | yellow: string;
50 | warningRed: string;
51 | onConnect: string;
52 |
53 | channelItemColor: string;
54 | channelBorder: string;
55 | threadHover: string;
56 | googleColor: string;
57 | };
58 |
59 | size: {
60 | xxxs: string;
61 | xxs: string;
62 | xs: string;
63 | s: string;
64 | m: string;
65 | l: string;
66 | xl: string;
67 | xxl: string;
68 | xxxl: string;
69 | };
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/client/src/styles/theme.ts:
--------------------------------------------------------------------------------
1 | import { DefaultTheme } from 'styled-components';
2 |
3 | const calcRem = (size: number) => `${size / 16}rem`;
4 |
5 | const theme: DefaultTheme = {
6 | boxShadow: {
7 | skyblue: '0 0 0 1px rgba(18, 100, 163, 1), 0 0 0 5px rgba(29, 155, 209, 0.3)',
8 | darkgray: '0 0 4px 10px rgba(0, 0, 0, 0.03), 0 0 4px 10px rgba(0, 0, 0, 0.03)',
9 | },
10 | color: {
11 | main: '#4a154b',
12 | lightBlack: '#1d1c1d',
13 | black1: '#111',
14 | black2: '#222',
15 | black3: '#333',
16 | black4: '#444',
17 | black5: '#555',
18 | black6: '#666',
19 | black7: '#777',
20 | black8: '#888',
21 | black9: '#999',
22 |
23 | gray1: '#aaa',
24 | gray2: '#bbb',
25 | gray3: '#ccc',
26 | gray4: '#ddd',
27 | gray5: '#eee',
28 | gray6: '#fff',
29 |
30 | lightGray1: '#d0d0d0',
31 | lightGray2: '#e0e0e0',
32 | lightGray3: '#f0f0f0',
33 |
34 | purple1: '#350d36',
35 | purple2: '#3f0e40',
36 | purple3: '#431e44',
37 |
38 | green1: '#007a5a',
39 | green2: '#008a6a',
40 | white: '#fff',
41 |
42 | blue1: '#0073c6',
43 | blue2: '#1164a3',
44 | blue3: '#0b4c8c',
45 | modalWhite: '#f9f9f9',
46 | semiWhite: '#fafbfc',
47 |
48 | yellow: '#e8912d',
49 | warningRed: '#D73A49',
50 | onConnect: '#2bac76',
51 |
52 | channelItemColor: '#bcabbc',
53 | channelBorder: 'rgb(82,38,83)',
54 | threadHover: '#faf9f9',
55 | googleColor: '#4285f4',
56 | },
57 | size: {
58 | xxxs: calcRem(8),
59 | xxs: calcRem(10),
60 | xs: calcRem(12),
61 | s: calcRem(14),
62 | m: calcRem(16),
63 | l: calcRem(18),
64 | xl: calcRem(20),
65 | xxl: calcRem(22),
66 | xxxl: calcRem(24),
67 | },
68 | };
69 |
70 | export default theme;
71 |
--------------------------------------------------------------------------------
/client/src/types/asset.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.svg' {
2 | const content: string;
3 | export default content;
4 | }
5 |
6 | declare module '*.webp' {
7 | const content: string;
8 | export default content;
9 | }
10 |
11 | declare module '*.gif' {
12 | const content: string;
13 | export default content;
14 | }
15 |
16 | declare module '*.woff';
17 | declare module '*.woff2';
18 | declare module '*.otf';
19 |
--------------------------------------------------------------------------------
/client/src/types/auth.ts:
--------------------------------------------------------------------------------
1 | export type AuthToken = 'ACCESS' | 'REFRESH';
2 |
--------------------------------------------------------------------------------
/client/src/types/channel.ts:
--------------------------------------------------------------------------------
1 | export interface Channel {
2 | id: number;
3 | ownerId: number;
4 | name: string;
5 | channelType: number;
6 | topic: string;
7 | isPublic: number;
8 | memberCount: number;
9 | description: string;
10 | createdAt?: string;
11 | updatedAt?: string;
12 | unreadMessage?: boolean;
13 | }
14 |
--------------------------------------------------------------------------------
/client/src/types/channelInfo.ts:
--------------------------------------------------------------------------------
1 | export interface ChannelInfo {
2 | ownerId: number;
3 | name: string;
4 | channelType: number;
5 | isPublic: number;
6 | description: string;
7 | memberCount: number;
8 | }
9 |
--------------------------------------------------------------------------------
/client/src/types/index.ts:
--------------------------------------------------------------------------------
1 | export * from './user';
2 | export * from './auth';
3 | export * from './thread';
4 | export * from './channelInfo';
5 | export * from './channel';
6 | export * from './service';
7 | export * from './socket';
8 |
--------------------------------------------------------------------------------
/client/src/types/service.ts:
--------------------------------------------------------------------------------
1 | export interface Service {
2 | [key: string]: (param?: any) => any;
3 | }
4 |
--------------------------------------------------------------------------------
/client/src/types/thread.ts:
--------------------------------------------------------------------------------
1 | export interface EmojiOfThread {
2 | id: number;
3 | userList: number[];
4 | }
5 |
6 | export interface Thread {
7 | [key: string]: any;
8 | id: number;
9 | userId: number;
10 | channelId: number;
11 | parentId: number | null;
12 | content: string;
13 | url: string;
14 | isEdited: number;
15 | isPinned: number;
16 | isDeleted: number;
17 | createdAt: string;
18 | emoji: EmojiOfThread[];
19 | subCount: number;
20 | subThreadUserId1: number | null;
21 | subThreadUserId2: number | null;
22 | subThreadUserId3: number | null;
23 | email: string;
24 | displayName: string;
25 | phoneNumber: string | null;
26 | image: string;
27 | }
28 |
29 | export const initialThread: Thread = {
30 | id: 0,
31 | userId: 0,
32 | channelId: 0,
33 | parentId: null,
34 | content: '첫번째 쓰레드를 작성해주세요! 😀',
35 | url: '',
36 | isEdited: 0,
37 | isPinned: 0,
38 | isDeleted: 0,
39 | createdAt: '',
40 | emoji: [],
41 | subCount: 0,
42 | subThreadUserId1: null,
43 | subThreadUserId2: null,
44 | subThreadUserId3: null,
45 | email: '',
46 | displayName: '',
47 | phoneNumber: null,
48 | image: '',
49 | };
50 |
51 | export interface ThreadResponse {
52 | result: {
53 | fieldCount: number;
54 | affectedRows: number;
55 | inserId: number;
56 | info: string;
57 | serverStatus: number;
58 | warningStatus: number;
59 | };
60 | }
61 |
--------------------------------------------------------------------------------
/client/src/types/user.ts:
--------------------------------------------------------------------------------
1 | export interface User {
2 | id: number;
3 | email: string;
4 | displayName: string;
5 | phoneNumber: string | null;
6 | image: string;
7 | lastChannelId: number | null;
8 | }
9 |
--------------------------------------------------------------------------------
/client/src/utils/constants.ts:
--------------------------------------------------------------------------------
1 | export const TIME_SEC = {
2 | FIVE_MINUTE: 60 * 5,
3 | TWO_MONTH: 60 * 60 * 24 * 60,
4 | };
5 |
6 | export const TIME_MILLIS = {
7 | FIVE_MINUTE: 1000 * 60 * 5,
8 | };
9 |
10 | export const TOKEN_TYPE = {
11 | ACCESS: 'ACCESS' as const,
12 | REFRESH: 'REFRESH' as const,
13 | };
14 |
15 | export const ERROR_MESSAGE = {
16 | MISSING_REQUIRED_VALUES: '필수 값 누락',
17 | INVALID_TOKEN: '유효하지 않은 토큰',
18 | WRONG_PW: '올바르지 않는 비밀번호',
19 | WRONG_USER: '올바르지 않는 유저 아이디',
20 | BLACKLIST_TOKEN: '블랙리스트 토큰',
21 | LOGIN_REQUIRED: '로그인 필요',
22 | };
23 |
24 | export const USER_DEFAULT_PROFILE_URL =
25 | 'https://user-images.githubusercontent.com/61396464/100866119-8c399c00-34db-11eb-894f-3551297f5293.png';
26 |
27 | export const CHANNEL_TYPE = {
28 | CHANNEL: 1,
29 | DM: 2,
30 | };
31 |
32 | export const INPUT_BOX_TYPE = {
33 | THREAD: 'thread',
34 | SUBTHREAD: 'subThread',
35 | EDIT: 'edit',
36 | };
37 |
38 | export const SOCKET_EVENT_TYPE = {
39 | CONNECT: 'connect',
40 | DISCONNECT: 'disconnect',
41 | MESSAGE: 'message',
42 | ENTER_ROOM: 'enter_room',
43 | LEAVE_ROOM: 'leave_room',
44 | };
45 |
46 | export const SOCKET_MESSAGE_TYPE = {
47 | THREAD: 'thread',
48 | EMOJI: 'emoji',
49 | USER: 'user',
50 | CHANNEL: 'channel',
51 | DM: 'dm',
52 | };
53 |
54 | export const CHANNEL_SUBTYPE = {
55 | UPDATE_CHANNEL: 'update_channel',
56 | UPDATE_CHANNEL_TOPIC: 'update_channel_topic',
57 | UPDATE_CHANNEL_UNREAD: 'update_channel_unread',
58 | UPDATE_CHANNEL_USERS: 'update_channel_users',
59 | MAKE_DM: 'make_dm',
60 | FIND_AND_JOIN_CHANNEL: 'find_and_join_channel',
61 | };
62 |
63 | export const THREAD_SUBTYPE = {
64 | CREATE_THREAD: 'create_thread',
65 | EDIT_THREAD: 'edit_thread',
66 | DELETE_THREAD: 'delete_thread',
67 | };
68 |
--------------------------------------------------------------------------------
/client/src/utils/utils.ts:
--------------------------------------------------------------------------------
1 | import jwt from 'jsonwebtoken';
2 | import config from '@/config';
3 | import { AuthToken, JoinedUser, Channel, User } from '@/types';
4 | import { TOKEN_TYPE } from '@/utils/constants';
5 |
6 | import crypto from 'crypto';
7 |
8 | const CIPHER_ALGORITHM = process.env.CIPHER_ALGORITHM as string;
9 | const CIPHER_KEY = process.env.CIPHER_KEY as string;
10 | const IV = process.env.IV as string;
11 |
12 | export const verifyJWT = (token: string, type: AuthToken): Promise => {
13 | return new Promise((resolve, reject) => {
14 | jwt.verify(
15 | token,
16 | type === TOKEN_TYPE.ACCESS ? config.jwtSecret : config.jwtRefreshSecret,
17 | (err, decoded) => {
18 | if (err) {
19 | reject(err);
20 | return;
21 | }
22 | resolve(decoded);
23 | },
24 | );
25 | });
26 | };
27 |
28 | export const makeUserIcons = (users: JoinedUser[]) => {
29 | const userIconList: JoinedUser[] = users.slice(0, 3);
30 | return userIconList;
31 | };
32 |
33 | export const encrypt = (text: string): string => {
34 | const cipher = crypto.createCipheriv(CIPHER_ALGORITHM, CIPHER_KEY, IV);
35 | return cipher.update(text, 'utf8', 'hex') + cipher.final('hex');
36 | };
37 |
38 | export const decrypt = (text: string): string => {
39 | const decipher = crypto.createDecipheriv(CIPHER_ALGORITHM, CIPHER_KEY, IV);
40 | return decipher.update(text, 'hex', 'utf8') + decipher.final('utf8');
41 | };
42 |
43 | export const getNotNullDataInArray = (arr: any[]) => (property: string | number) => {
44 | return arr.filter((el) => {
45 | return el[property];
46 | });
47 | };
48 |
49 | export const isNumberTypeValue = (value: any): boolean => {
50 | const numberedValue = Number(value);
51 | return !Number.isNaN(numberedValue);
52 | };
53 |
54 | export const isExistedChannel = ({
55 | channelId,
56 | myChannelList,
57 | }: {
58 | channelId: number;
59 | myChannelList: Channel[];
60 | }): boolean => {
61 | return myChannelList.some((channel: Channel) => channel.id === channelId);
62 | };
63 |
64 | export const makeDMRoomName = (pickUsers: User[], startName: string) => {
65 | const name = pickUsers.reduce((acc, cur) => {
66 | return `${acc}, ${cur.displayName}`;
67 | }, startName);
68 | return name;
69 | };
70 |
71 | export const getFormattedDate = (d: Date): string => {
72 | const year = d.getFullYear();
73 | const month = d.getMonth() + 1;
74 | const date = d.getDate();
75 | const hour = d.getHours();
76 | const minute = d.getMinutes();
77 | const second = d.getSeconds();
78 |
79 | return `${year}-${`0${month}`.slice(-2)}-${`0${date}`.slice(-2)} ${`0${hour}`.slice(
80 | -2,
81 | )}:${`0${minute}`.slice(-2)}:${`0${second}`.slice(-2)}`;
82 | };
83 |
--------------------------------------------------------------------------------
/client/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "ESNext"],
5 | "baseUrl": "./src",
6 | "paths": {
7 | "@/*": ["./*"]
8 | },
9 | "typeRoots": ["./node_modules/@types", "./src/types"],
10 | "allowJs": true,
11 | "skipLibCheck": true,
12 | "esModuleInterop": true,
13 | "allowSyntheticDefaultImports": true,
14 | "strict": true,
15 | "noImplicitAny": true,
16 | "sourceMap": true,
17 | "forceConsistentCasingInFileNames": true,
18 | "moduleResolution": "node",
19 | "resolveJsonModule": true,
20 | "jsx": "react",
21 | "rootDir": "./src",
22 | "outDir": "./dist",
23 | "module": "ESNext"
24 | },
25 | "include": ["./src/**/*"]
26 | }
27 |
--------------------------------------------------------------------------------
/deploy.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | git checkout master
4 | git fetch
5 |
6 | head=`git rev-parse HEAD`
7 | origin=`git rev-parse origin/master`
8 |
9 | if [ $head != $origin ]; then
10 | git pull
11 | cd /root/Project06-A-Slack/server
12 | npm i
13 | npm run build
14 | npm run start
15 | cd /root/Project06-A-Slack/client
16 | npm i
17 | npm run build
18 | pm2 reload app
19 | fi
20 | echo 'finish!'
21 |
--------------------------------------------------------------------------------
/server/.eslintrc.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | module.exports = {
4 | env: {
5 | es2021: true,
6 | node: true,
7 | },
8 | extends: [
9 | 'airbnb-base',
10 | 'plugin:@typescript-eslint/eslint-recommended',
11 | 'plugin:@typescript-eslint/recommended',
12 | 'prettier/@typescript-eslint',
13 | 'plugin:prettier/recommended',
14 | ],
15 | parser: '@typescript-eslint/parser',
16 | parserOptions: {
17 | ecmaVersion: 12,
18 | sourceType: 'module',
19 | },
20 | plugins: ['@typescript-eslint', 'prettier'],
21 | rules: {
22 | 'prettier/prettier': 'error', // prettier, eslint 충돌 없애는 것
23 | 'no-console': 'warn', // console에 warn
24 | 'no-plusplus': ['error', { allowForLoopAfterthoughts: true }], // loop 안에서만 허용
25 | 'class-methods-use-this': 'off', // 클래스에 this를 안쓰면 스태틱으로 바꿔야한다? no.
26 | 'no-restricted-globals': 'warn',
27 | 'import/extensions': [
28 | 'error',
29 | 'always',
30 | {
31 | js: 'never',
32 | jsx: 'never',
33 | ts: 'never',
34 | tsx: 'never',
35 | },
36 | ],
37 | 'import/prefer-default-export': 'off', // 한 개만 export할때는 export default를 쓰도록 하는 옵션
38 | },
39 | settings: {
40 | 'import/resolver': {
41 | typescript: {
42 | project: path.join(__dirname, './tsconfig.json'),
43 | },
44 | },
45 | },
46 | ignorePatterns: ['node_modules', 'babel.config.js', 'webpack.config.js', '.eslintrc.js'],
47 | };
48 |
--------------------------------------------------------------------------------
/server/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .env
3 | src/public/imgs/etc/*
4 | src/public/imgs/profile/*
5 | !src/public/imgs/etc/.keep
6 | !src/public/imgs/profile/.keep
7 | dist
--------------------------------------------------------------------------------
/server/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 100,
3 | "tabWidth": 2,
4 | "singleQuote": true,
5 | "trailingComma": "all",
6 | "bracketSpacing": true,
7 | "semi": true,
8 | "useTabs": false,
9 | "arrowParens": "always",
10 | "endOfLine": "auto"
11 | }
--------------------------------------------------------------------------------
/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "slack-server",
3 | "version": "0.0.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "cross-env MODE=dev nodemon -e yaml,ts,js,json ./src/app.ts",
7 | "clear": "tsc --build --clean",
8 | "build": "tsc -p tsconfig.json && tscpaths -p tsconfig.json -s ./src -o ./dist",
9 | "start": "pm2 start ./dist/app.js"
10 | },
11 | "_moduleAliases": {
12 | "@": "./src"
13 | },
14 | "dependencies": {
15 | "axios": "^0.21.0",
16 | "bcrypt": "^5.0.0",
17 | "cookie-parser": "^1.4.5",
18 | "cors": "^2.8.5",
19 | "debug": "^4.2.0",
20 | "dotenv": "^8.2.0",
21 | "express": "^4.17.1",
22 | "express-mysql-session": "^2.1.4",
23 | "express-session": "^1.17.1",
24 | "formidable": "^1.2.2",
25 | "http-errors": "^1.8.0",
26 | "jsonwebtoken": "^8.5.1",
27 | "module-alias": "^2.2.2",
28 | "morgan": "^1.10.0",
29 | "mysql2": "^2.2.5",
30 | "nodemailer": "^6.4.16",
31 | "nodemailer-smtp-transport": "^2.7.4",
32 | "passport": "^0.4.1",
33 | "passport-google-oauth20": "^2.0.0",
34 | "socket.io": "^3.0.2",
35 | "swagger-jsdoc": "^5.0.1",
36 | "swagger-ui-express": "^4.1.5",
37 | "validator": "^13.1.17"
38 | },
39 | "devDependencies": {
40 | "@types/async-redis": "^1.1.1",
41 | "@types/bcrypt": "^3.0.0",
42 | "@types/cookie-parser": "^1.4.2",
43 | "@types/cors": "^2.8.8",
44 | "@types/express": "^4.17.9",
45 | "@types/express-mysql-session": "^2.1.2",
46 | "@types/express-session": "^1.17.3",
47 | "@types/formidable": "^1.0.31",
48 | "@types/http-errors": "^1.8.0",
49 | "@types/jsonwebtoken": "^8.5.0",
50 | "@types/module-alias": "^2.0.0",
51 | "@types/morgan": "^1.9.2",
52 | "@types/node": "^14.14.9",
53 | "@types/nodemailer": "^6.4.0",
54 | "@types/nodemailer-smtp-transport": "^2.7.4",
55 | "@types/passport-google-oauth20": "^2.0.4",
56 | "@types/socket.io": "^2.1.11",
57 | "@types/swagger-jsdoc": "^3.0.2",
58 | "@types/swagger-ui-express": "^4.1.2",
59 | "@types/validator": "^13.1.0",
60 | "@typescript-eslint/eslint-plugin": "^4.7.0",
61 | "@typescript-eslint/parser": "^4.8.1",
62 | "cross-env": "^7.0.2",
63 | "eslint": "^7.13.0",
64 | "eslint-config-airbnb-base": "^14.2.1",
65 | "eslint-config-prettier": "^6.15.0",
66 | "eslint-import-resolver-alias": "^1.1.2",
67 | "eslint-import-resolver-typescript": "^2.3.0",
68 | "eslint-plugin-import": "^2.22.1",
69 | "eslint-plugin-prettier": "^3.1.4",
70 | "nodemon": "^2.0.6",
71 | "prettier": "^2.1.2",
72 | "ts-node": "^9.0.0",
73 | "tsc-alias": "^1.1.5",
74 | "tsc-watch": "^4.2.9",
75 | "tsconfig-paths": "^3.9.0",
76 | "tscpaths": "0.0.9",
77 | "typescript": "^4.0.5"
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/server/src/app.ts:
--------------------------------------------------------------------------------
1 | import 'module-alias/register';
2 | import 'dotenv/config';
3 | import express, { Request, Response, NextFunction } from 'express';
4 | import path from 'path';
5 | import logger from 'morgan';
6 | import cookieParser from 'cookie-parser';
7 | import createError from 'http-errors';
8 | import cors from 'cors';
9 | import http from 'http';
10 | import swaggerUi from 'swagger-ui-express';
11 | import swaggerJSDoc from 'swagger-jsdoc';
12 | import { Error } from '@/types';
13 | import config from '@/config';
14 | import apiRouter from '@/routes/api';
15 | import { bindSocketServer } from '@/lib/socket';
16 | import passport from 'passport';
17 | import passportConfig from '@/lib/passport';
18 |
19 | const port = process.env.PORT || 3000;
20 | const app = express();
21 | const server = http.createServer(app);
22 |
23 | bindSocketServer(server);
24 |
25 | /* Swagger */
26 | const options = {
27 | swaggerDefinition: config.swaggerDefinition,
28 | apis: ['./src/routes/**/*.yaml'],
29 | };
30 |
31 | const swaggerSpec = swaggerJSDoc(options);
32 |
33 | app.set('port', port);
34 | app.use(cors());
35 | app.use(logger('dev'));
36 | app.use(express.json());
37 | app.use(express.urlencoded({ extended: false }));
38 | app.use(cookieParser());
39 | app.use(express.static(path.join(__dirname, 'public')));
40 | app.use(express.static(path.join(__dirname, '../../client/dist')));
41 | app.use(passport.initialize());
42 | passportConfig();
43 |
44 | app.use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
45 | app.use('/api', apiRouter);
46 | app.all('*', (req, res) => {
47 | if (process.env.MODE === 'dev') {
48 | res.redirect(config.clientHost);
49 | return;
50 | }
51 | res.sendFile(path.join(__dirname, '../../client/dist/index.html'));
52 | });
53 |
54 | app.use((req, res, next) => {
55 | next(createError(404));
56 | });
57 |
58 | app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
59 | console.error(err);
60 | const { message, status = 500 } = err;
61 | res.status(status).json({ message, status });
62 | });
63 |
64 | server.listen(port, () => {
65 | console.log(`Listening on port ${port}`);
66 | });
67 |
--------------------------------------------------------------------------------
/server/src/config/index.ts:
--------------------------------------------------------------------------------
1 | import { PoolOptions } from 'mysql2/promise';
2 |
3 | interface Config {
4 | clientHost: string;
5 | serverHost: string;
6 | oauth: {
7 | google: {
8 | clientID: string;
9 | clientSecret: string;
10 | callbackURL: string;
11 | };
12 | };
13 | jwtSecret: string;
14 | jwtRefreshSecret: string;
15 | devDB: PoolOptions;
16 | DB: PoolOptions;
17 | swaggerDefinition: any;
18 | NODE_MAILER: {
19 | email: string;
20 | pw: string;
21 | };
22 | }
23 |
24 | const config: Config = {
25 | clientHost:
26 | process.env.MODE === 'dev'
27 | ? (process.env.DEV_CLIENT_HOST as string)
28 | : (process.env.HOST as string),
29 | serverHost:
30 | process.env.MODE === 'dev'
31 | ? (process.env.DEV_SERVER_HOST as string)
32 | : (process.env.HOST as string),
33 | oauth: {
34 | google: {
35 | clientID: process.env.GOOGLE_CLIENT_ID as string,
36 | clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
37 | callbackURL: `${
38 | process.env.MODE === 'dev'
39 | ? (process.env.DEV_SERVER_HOST as string)
40 | : (process.env.HOST as string)
41 | }/api/oauth/google/callback`,
42 | },
43 | },
44 | jwtSecret: process.env.JWT_SECRET as string,
45 | jwtRefreshSecret: process.env.JWT_REFRESH_SECRET as string,
46 | NODE_MAILER: {
47 | email: process.env.NODEMAILER_EMAIL as string,
48 | pw: process.env.NODEMAILER_PW as string,
49 | },
50 | devDB: {
51 | host: process.env.DB_DEV_HOST,
52 | user: process.env.DB_DEV_USER,
53 | password: process.env.DB_DEV_PASS,
54 | port: Number(process.env.DB_DEV_PORT),
55 | database: process.env.DB_DEV_NAME,
56 | connectionLimit: 20,
57 | dateStrings: ['DATE'],
58 | multipleStatements: true,
59 | },
60 | DB: {
61 | host: process.env.DB_HOST,
62 | user: process.env.DB_USER,
63 | password: process.env.DB_PASS,
64 | port: Number(process.env.DB_PORT),
65 | database: process.env.DB_NAME,
66 | connectionLimit: 20,
67 | dateStrings: ['DATE'],
68 | multipleStatements: true,
69 | },
70 | swaggerDefinition: {
71 | openapi: '3.0.0',
72 | info: {
73 | title: 'Project-06-Slack', // Title (required)
74 | version: '1.0.0', // Version (required)
75 | description: 'Slack API', // Description (optional)
76 | },
77 | host: 'localhost:3000', // Host (optional)
78 | basePath: '/', // Base path (optional)
79 | consumes: 'application/json',
80 | produces: 'application/json',
81 | components: {
82 | securitySchemes: {
83 | bearerAuth: {
84 | type: 'http',
85 | scheme: 'bearer',
86 | bearerFormat: 'JWT',
87 | },
88 | },
89 | },
90 | security: [
91 | {
92 | bearerAuth: [],
93 | },
94 | ],
95 | },
96 | };
97 |
98 | export default config;
99 |
--------------------------------------------------------------------------------
/server/src/db/index.ts:
--------------------------------------------------------------------------------
1 | import mysql2 from 'mysql2/promise';
2 | import config from '@/config';
3 |
4 | const pool = mysql2.createPool(process.env.MODE === 'dev' ? config.devDB : config.DB);
5 |
6 | export default pool;
7 |
--------------------------------------------------------------------------------
/server/src/lib/passport.ts:
--------------------------------------------------------------------------------
1 | import config from '@/config';
2 | import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
3 | import passport from 'passport';
4 |
5 | export default () => {
6 | passport.use(
7 | new GoogleStrategy(
8 | {
9 | clientID: config.oauth.google.clientID,
10 | clientSecret: config.oauth.google.clientSecret,
11 | callbackURL: config.oauth.google.callbackURL,
12 | },
13 | async (accessToken, refreshToken, profile, done) => {
14 | done(undefined, { accessToken });
15 | },
16 | ),
17 | );
18 | };
19 |
--------------------------------------------------------------------------------
/server/src/middlewares/auth.middleware.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response, NextFunction } from 'express';
2 | import jwt from 'jsonwebtoken';
3 | import config from '@/config';
4 | import { ERROR_MESSAGE } from '@/utils/constants';
5 |
6 | export const authenticated = async (
7 | req: Request,
8 | res: Response,
9 | next: NextFunction,
10 | ): Promise => {
11 | const authToken = req.headers.authorization?.split('Bearer ')[1];
12 | if (authToken) {
13 | jwt.verify(authToken, config.jwtSecret, (err, decoded) => {
14 | if (err) {
15 | res.status(401).json({ message: ERROR_MESSAGE.INVALID_TOKEN });
16 | return;
17 | }
18 |
19 | if (decoded) {
20 | res.locals.authToken = authToken;
21 | next();
22 | }
23 | });
24 | return;
25 | }
26 | res.status(401).json({ message: ERROR_MESSAGE.LOGIN_REQUIRED });
27 | };
28 |
--------------------------------------------------------------------------------
/server/src/models/emoji.model.ts:
--------------------------------------------------------------------------------
1 | import pool from '@/db';
2 | import { Model } from '@/types';
3 |
4 | export const emojiModel: Model = {
5 | getEmojiList() {
6 | const sql = `SELECT id, name, url from emoji`;
7 | return pool.execute(sql);
8 | },
9 | createEmoji({ name, url }: { name: string; url: string }) {
10 | const sql = ``;
11 | return pool.execute(sql, [name, url]);
12 | },
13 | };
14 |
--------------------------------------------------------------------------------
/server/src/models/index.ts:
--------------------------------------------------------------------------------
1 | export { userModel } from './user.model';
2 | export { channelModel } from './channels.model';
3 | export { threadModel } from './thread.model';
4 | export { emojiModel } from './emoji.model';
5 |
--------------------------------------------------------------------------------
/server/src/public/imgs/etc/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcamp-2020/Project06-A-Slack/27585ed84e3da4655929fbc9b2fc535e68ec31f7/server/src/public/imgs/etc/.keep
--------------------------------------------------------------------------------
/server/src/public/imgs/profile/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcamp-2020/Project06-A-Slack/27585ed84e3da4655929fbc9b2fc535e68ec31f7/server/src/public/imgs/profile/.keep
--------------------------------------------------------------------------------
/server/src/routes/api/auth/index.ts:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import * as authController from './auth.controller';
3 |
4 | const router = express.Router({ mergeParams: true });
5 |
6 | router.post('/login', authController.login);
7 | router.post('/logout', authController.logout);
8 | router.post('/signup', authController.signup);
9 | router.post('/token/refresh', authController.refreshAuthToken);
10 | router.post('/email/verify', authController.verifyEmail);
11 | router.post('/email/check', authController.checkExistEmail);
12 |
13 | export default router;
14 |
--------------------------------------------------------------------------------
/server/src/routes/api/channels/[channelId]/channelsId.yaml:
--------------------------------------------------------------------------------
1 | paths:
2 | /api/channels/{channelId}:
3 | get:
4 | parameters:
5 | - in: path
6 | name: channelId
7 | schema:
8 | type: number
9 | required: true
10 | tags:
11 | - description: Channels
12 | summary: 채널을 한 개 반환
13 | responses:
14 | 200:
15 | description: 채널을 한 개 반환
16 | content:
17 | application/json:
18 | schema:
19 | $ref: '#/components/schemas/Channel'
20 | default:
21 | description: Error
22 | content:
23 | application/json:
24 | schema:
25 | $ref: '#/components/schemas/ErrorResponse'
26 | post:
27 | tags:
28 | - description: Channels
29 | summary: '채널 수정'
30 | parameters:
31 | - in: path
32 | name: channelId
33 | schema:
34 | type: number
35 | required: true
36 | requestBody:
37 | required: true
38 | content:
39 | application/json:
40 | schema:
41 | type: object
42 | properties:
43 | title:
44 | type: string
45 | responses:
46 | 200:
47 | description: 채널 수정 성공
48 | default:
49 | description: Error
50 | content:
51 | application/json:
52 | schema:
53 | $ref: '#/components/schemas/ErrorResponse'
54 | delete:
55 | parameters:
56 | - in: path
57 | name: channelId
58 | schema:
59 | type: number
60 | required: true
61 | tags:
62 | - description: Channels
63 | summary: 채널 삭제
64 | responses:
65 | 200:
66 | description: 채널 삭제 성공
67 | default:
68 | description: Error
69 | content:
70 | application/json:
71 | schema:
72 | $ref: '#/components/schemas/ErrorResponse'
73 | /api/channels/{channelId}/invite:
74 | post:
75 | tags:
76 | - description: Channels
77 | summary: '유저를 초대'
78 | parameters:
79 | - in: path
80 | name: channelId
81 | schema:
82 | type: number
83 | required: true
84 | requestBody:
85 | required: true
86 | content:
87 | application/json:
88 | schema:
89 | type: object
90 | properties:
91 | userId:
92 | type: number
93 | responses:
94 | 200:
95 | description: 채널 초대 성공
96 | default:
97 | description: Error
98 | content:
99 | application/json:
100 | schema:
101 | $ref: '#/components/schemas/ErrorResponse'
102 |
--------------------------------------------------------------------------------
/server/src/routes/api/channels/[channelId]/index.ts:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import * as channelIdController from './channelId.controller';
3 |
4 | const router = express.Router({ mergeParams: true });
5 |
6 | router.get('/', channelIdController.getChannel);
7 | router.post('/', channelIdController.modifyChannel);
8 | router.delete('/', channelIdController.deleteChannel);
9 | router.post('/invite', channelIdController.inviteChannel);
10 | router.post('/topic', channelIdController.modifyTopic);
11 | router.post('/unread', channelIdController.setChannelUnreadFlag);
12 |
13 | export default router;
14 |
--------------------------------------------------------------------------------
/server/src/routes/api/channels/channels.controller.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response, NextFunction } from 'express';
2 | import { verifyRequestData } from '@/utils/utils';
3 | import { channelModel } from '@/models';
4 | /**
5 | * GET /api/channels
6 | */
7 | export const getChannels = async (req: Request, res: Response, next: NextFunction) => {
8 | const [channelList] = await channelModel.getChannels();
9 | res.json({ channelList });
10 | };
11 |
12 | /**
13 | * POST /api/channels
14 | */
15 | export const createChannel = async (req: Request, res: Response, next: NextFunction) => {
16 | const { ownerId, name, channelType, isPublic, description, memberCount } = req.body;
17 | if (verifyRequestData([ownerId, name, channelType, isPublic, memberCount])) {
18 | const [channel] = await channelModel.createChannel({
19 | ownerId,
20 | name,
21 | channelType,
22 | isPublic,
23 | description,
24 | memberCount,
25 | });
26 | res.status(201).json({ channel });
27 | return;
28 | }
29 | res.status(400).json({ message: '필수 값 누락' });
30 | };
31 |
32 | /**
33 | * POST /api/channels/check-duplicated
34 | */
35 | export const checkDuplicatedChannel = async (req: Request, res: Response): Promise => {
36 | const { channelName } = req.body;
37 | if (verifyRequestData([channelName])) {
38 | const [[channelId]] = await channelModel.checkDuplicatedChannel({ channelName });
39 | if (channelId) {
40 | res.json({ isDuplicated: true });
41 | return;
42 | }
43 | res.json({ isDuplicated: false });
44 | return;
45 | }
46 | res.status(400).json({ message: '필수 값 누락' });
47 | };
48 |
--------------------------------------------------------------------------------
/server/src/routes/api/channels/channels.yaml:
--------------------------------------------------------------------------------
1 | components:
2 | schemas:
3 | Channel:
4 | type: object
5 | properties:
6 | id:
7 | type: number
8 | ownerId:
9 | type: number
10 | name:
11 | type: string
12 | channelType:
13 | type: number
14 | isPublic:
15 | type: number
16 | memberCount:
17 | type: number
18 | description:
19 | type: string
20 | nullable: true
21 |
22 | paths:
23 | /api/channels:
24 | get:
25 | tags:
26 | - description: Channels
27 | summary: 채널 리스트를 반환
28 | responses:
29 | 200:
30 | description: 채널 리스트를 반환
31 | content:
32 | application/json:
33 | schema:
34 | type: array
35 | items:
36 | $ref: '#/components/schemas/Channel'
37 |
38 | default:
39 | description: Error
40 | content:
41 | application/json:
42 | schema:
43 | $ref: '#/components/schemas/ErrorResponse'
44 | post:
45 | tags:
46 | - description: Channels
47 | summary: 채널을 생성
48 | requestBody:
49 | required: true
50 | content:
51 | application/json:
52 | schema:
53 | type: object
54 | properties:
55 | name:
56 | type: string
57 | channelType:
58 | type: number
59 | isPublic:
60 | type: number
61 | description:
62 | type: string
63 | responses:
64 | 201:
65 | description: 채널 생성 성공
66 | default:
67 | description: Error
68 | content:
69 | application/json:
70 | schema:
71 | $ref: '#/components/schemas/ErrorResponse'
72 |
--------------------------------------------------------------------------------
/server/src/routes/api/channels/index.ts:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import * as channelsController from './channels.controller';
3 | import channelIdRouter from './[channelId]';
4 |
5 | const router = express.Router();
6 |
7 | router.get('/', channelsController.getChannels);
8 | router.post('/', channelsController.createChannel);
9 | router.post('/check-duplicated', channelsController.checkDuplicatedChannel);
10 | router.use('/:channelId', channelIdRouter);
11 |
12 | export default router;
13 |
--------------------------------------------------------------------------------
/server/src/routes/api/emojis/emojis.controllers.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response, NextFunction } from 'express';
2 | import { emojiModel } from '@/models';
3 | import { verifyRequestData } from '@/utils/utils';
4 |
5 | /**
6 | * GET /api/emojis
7 | */
8 | export const getEmojiList = async (
9 | req: Request,
10 | res: Response,
11 | next: NextFunction,
12 | ): Promise => {
13 | try {
14 | const [emojiList] = await emojiModel.getEmojiList();
15 | res.status(200).json({ emojiList });
16 | } catch (err) {
17 | next(err);
18 | }
19 | };
20 |
21 | /**
22 | * POST /api/emojis
23 | */
24 | export const createEmoji = async (
25 | req: Request,
26 | res: Response,
27 | next: NextFunction,
28 | ): Promise => {
29 | try {
30 | const { name, url } = req.body;
31 | if (verifyRequestData([name, url])) {
32 | const [createedEmojiInfo] = await emojiModel.createEmoji({ name, url });
33 | res.status(201).json({ createedEmojiInfo });
34 | return;
35 | }
36 | } catch (err) {
37 | next(err);
38 | }
39 | };
40 |
--------------------------------------------------------------------------------
/server/src/routes/api/emojis/index.ts:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import * as emojiController from './emojis.controllers';
3 |
4 | const router = express.Router();
5 |
6 | router.get('/', emojiController.getEmojiList);
7 | router.post('/', emojiController.createEmoji);
8 |
9 | export default router;
10 |
--------------------------------------------------------------------------------
/server/src/routes/api/index.ts:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import { authenticated } from '@/middlewares/auth.middleware';
3 | import userRouter from './users';
4 | import channelRouter from './channels';
5 | import authRouter from './auth';
6 | import threadRouter from './threads';
7 | import emojiRouter from './emojis';
8 | import oauthRouter from './oauth';
9 |
10 | const router = express.Router();
11 |
12 | router.use('/users', authenticated, userRouter);
13 | router.use('/channels', authenticated, channelRouter);
14 | router.use('/auth', authRouter);
15 | router.use('/threads', authenticated, threadRouter);
16 | router.use('/emojis', authenticated, emojiRouter);
17 | router.use('/oauth', oauthRouter);
18 |
19 | export default router;
20 |
--------------------------------------------------------------------------------
/server/src/routes/api/oauth/index.ts:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import * as oauthController from './oauth.controller';
3 |
4 | const router = express.Router();
5 |
6 | router.get('/google', oauthController.oauthLogin);
7 | router.get('/google/callback', oauthController.handleAuth, oauthController.handleSuccess);
8 | router.get('/google/failure', oauthController.failure);
9 | router.post('/google/signup', oauthController.googleSignup);
10 |
11 | export default router;
12 |
--------------------------------------------------------------------------------
/server/src/routes/api/oauth/oauth.controller.ts:
--------------------------------------------------------------------------------
1 | import passport from 'passport';
2 | import { NextFunction, Request, Response } from 'express';
3 | import jwt from 'jsonwebtoken';
4 | import config from '@/config';
5 | import { ERROR_MESSAGE, TIME } from '@/utils/constants';
6 | import { User } from '@/types';
7 | import axios from 'axios';
8 | import { verifyRequestData } from '@/utils/utils';
9 | import { channelModel, userModel } from '@/models';
10 |
11 | /**
12 | * GET /api/oauth/google
13 | */
14 | export const oauthLogin = passport.authenticate('google', { scope: ['profile', 'email'] });
15 |
16 | /**
17 | * GET /api/oauth/google/callback
18 | */
19 | export const handleAuth = passport.authenticate('google', {
20 | session: false,
21 | failureRedirect: '/api/oauth/google/failure',
22 | });
23 |
24 | export const handleSuccess = async (req: Request, res: Response): Promise => {
25 | const { user }: any = req;
26 | if (user) {
27 | res.redirect(`${config.clientHost}/login?accessToken=${user.accessToken}`);
28 | return;
29 | }
30 | res.status(401).json({ message: ERROR_MESSAGE.GOOGLE_OAUTH_FAILED });
31 | };
32 |
33 | /**
34 | * GET /api/oauth/google/failure
35 | */
36 | export const failure = (req: Request, res: Response): void => {
37 | res.status(401).json({ message: ERROR_MESSAGE.GOOGLE_OAUTH_FAILED });
38 | };
39 |
40 | /**
41 | * POST /api/oauth/google/signup
42 | */
43 | export const googleSignup = async (
44 | req: Request,
45 | res: Response,
46 | next: NextFunction,
47 | ): Promise => {
48 | const { accessToken: googleAccessToken } = req.body;
49 | try {
50 | const { data, status } = await axios.get('https://www.googleapis.com/oauth2/v3/userinfo', {
51 | headers: { Authorization: `Bearer ${googleAccessToken}` },
52 | });
53 | if (status === 200) {
54 | const { email, name, picture } = data;
55 | if (verifyRequestData([email, name, picture])) {
56 | const [[user]]: [[User]] = await userModel.getUserByEmail({ email });
57 |
58 | let userInfo;
59 | if (user) {
60 | userInfo = { email: user.email, displayName: user.displayName, id: user.id };
61 | } else {
62 | const [{ insertId }] = picture
63 | ? await userModel.addOAuthUser({ email, displayName: name, image: picture })
64 | : await userModel.addOAuthUser({ email, displayName: name });
65 | await channelModel.setUserChannel({ userId: insertId });
66 | userInfo = { email, displayName: name, id: insertId };
67 | }
68 |
69 | const accessToken = jwt.sign(userInfo, config.jwtSecret, { expiresIn: TIME.FIVE_MINUTE });
70 | const refreshToken = jwt.sign(userInfo, config.jwtRefreshSecret, {
71 | expiresIn: TIME.TWO_MONTH,
72 | });
73 |
74 | res.json({ accessToken, refreshToken, user: userInfo });
75 | return;
76 | }
77 | }
78 | res.status(401).json({ message: ERROR_MESSAGE.GOOGLE_OAUTH_SIGNUP_FAILED });
79 | } catch (err) {
80 | next(err);
81 | }
82 | };
83 |
--------------------------------------------------------------------------------
/server/src/routes/api/threads/[threadId]/index.ts:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import * as threadIdController from './threadId.controller';
3 |
4 | const router = express.Router({ mergeParams: true });
5 |
6 | router.get('/', threadIdController.getSubThread);
7 | router.post('/', threadIdController.modifyThread);
8 | router.delete('/', threadIdController.deleteThread);
9 | router.post('/pin', threadIdController.pinThread);
10 |
11 | export default router;
12 |
--------------------------------------------------------------------------------
/server/src/routes/api/threads/[threadId]/threadId.controller.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response, NextFunction } from 'express';
2 | import { verifyRequestData } from '@/utils/utils';
3 | import { threadModel } from '@/models';
4 | import { ERROR_MESSAGE } from '@/utils/constants';
5 |
6 | /**
7 | * GET /api/threads/:threadId
8 | */
9 | export const getSubThread = async (
10 | req: Request,
11 | res: Response,
12 | next: NextFunction,
13 | ): Promise => {
14 | const { threadId } = req.params;
15 | if (Number.isNaN(Number(threadId))) {
16 | next({ message: ERROR_MESSAGE.WRONG_PARAMS, status: 400 });
17 | return;
18 | }
19 | try {
20 | const [parentThread] = await threadModel.getThread({ threadId: +threadId });
21 | const [subThreadList] = await threadModel.getSubThreadList({ threadId: +threadId });
22 | res.json({ parentThread, subThreadList });
23 | } catch (err) {
24 | next(err);
25 | }
26 | };
27 |
28 | /**
29 | * 특정 쓰레드 수정
30 | * POST /api/threads/:threadId
31 | */
32 | export const modifyThread = async (
33 | req: Request,
34 | res: Response,
35 | next: NextFunction,
36 | ): Promise => {
37 | const { threadId } = req.params;
38 | const { content } = req.body;
39 |
40 | if (verifyRequestData([content])) {
41 | try {
42 | const [result] = await threadModel.updateThread({ content, threadId: +threadId });
43 | res.status(200).json({ result });
44 | return;
45 | } catch (err) {
46 | next(err);
47 | }
48 | }
49 | res.status(400).json({ message: ERROR_MESSAGE.MISSING_REQUIRED_VALUES });
50 | };
51 |
52 | /**
53 | * 쓰레드의 is_deteled를 1로 변경
54 | * DELETE /api/threads/:threadId
55 | */
56 | export const deleteThread = async (
57 | req: Request,
58 | res: Response,
59 | next: NextFunction,
60 | ): Promise => {
61 | const { threadId } = req.params;
62 | if (verifyRequestData([threadId])) {
63 | try {
64 | const [result] = await threadModel.deleteThread({ threadId: +threadId });
65 | res.status(200).json({ result });
66 | return;
67 | } catch (err) {
68 | next(err);
69 | }
70 | }
71 | res.status(400).json({ message: ERROR_MESSAGE.MISSING_REQUIRED_VALUES });
72 | };
73 |
74 | /**
75 | * POST /api/threads/:threadId/pin
76 | */
77 | export const pinThread = async (req: Request, res: Response, next: NextFunction): Promise => {
78 | const { threadId } = req.params;
79 | if (verifyRequestData([threadId])) {
80 | res.status(200).end();
81 | return;
82 | }
83 | res.status(400).json({ message: ERROR_MESSAGE.MISSING_REQUIRED_VALUES });
84 | };
85 |
--------------------------------------------------------------------------------
/server/src/routes/api/threads/[threadId]/threadId.yaml:
--------------------------------------------------------------------------------
1 | paths:
2 | /api/threads/{threadId}:
3 | get:
4 | tags:
5 | - description: Thread
6 | summary: 특정 쓰레드 조회
7 | parameters:
8 | - in: path
9 | name: threadId
10 | schema:
11 | type: integer
12 | required: true
13 | responses:
14 | 200:
15 | description: OK
16 | content:
17 | application/json:
18 | schema:
19 | $ref: '#/components/schemas/Thread'
20 | default:
21 | description: Error
22 | content:
23 | application/json:
24 | schema:
25 | $ref: '#/components/schemas/ErrorResponse'
26 |
27 | post:
28 | tags:
29 | - description: Thread
30 | summary: 특정 쓰레드 수정
31 | parameters:
32 | - in: path
33 | name: threadId
34 | schema:
35 | type: integer
36 | required: true
37 | requestBody:
38 | required: true
39 | content:
40 | application/json:
41 | schema:
42 | type: object
43 | required:
44 | - content
45 | properties:
46 | content:
47 | type: string
48 | responses:
49 | 200:
50 | description: OK
51 | default:
52 | description: Error
53 | content:
54 | application/json:
55 | schema:
56 | $ref: '#/components/schemas/ErrorResponse'
57 |
58 | delete:
59 | tags:
60 | - description: Thread
61 | summary: 특정 쓰레드 삭제
62 | parameters:
63 | - in: path
64 | name: threadId
65 | schema:
66 | type: integer
67 | required: true
68 | responses:
69 | 200:
70 | description: OK
71 | default:
72 | description: Error
73 | content:
74 | application/json:
75 | schema:
76 | $ref: '#/components/schemas/ErrorResponse'
77 |
78 | /api/threads/{threadId}/pin:
79 | post:
80 | tags:
81 | - description: Thread
82 | summary: 특정 쓰레드에 핀추가
83 | parameters:
84 | - in: path
85 | name: threadId
86 | schema:
87 | type: integer
88 | required: true
89 | responses:
90 | 200:
91 | description: OK
92 | default:
93 | description: Error
94 | content:
95 | application/json:
96 | schema:
97 | $ref: '#/components/schemas/ErrorResponse'
98 |
--------------------------------------------------------------------------------
/server/src/routes/api/threads/index.ts:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import * as threadsController from './threads.controller';
3 | import threadIdRouter from './[threadId]';
4 |
5 | const router = express.Router();
6 |
7 | router.post('/', threadsController.createThread);
8 | router.use('/:threadId', threadIdRouter);
9 | router.get('/channels/:channelId', threadsController.getChannelThreads);
10 |
11 | export default router;
12 |
--------------------------------------------------------------------------------
/server/src/routes/api/threads/threads.controller.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response, NextFunction } from 'express';
2 | import { verifyRequestData } from '@/utils/utils';
3 | import { threadModel } from '@/models';
4 | import { ERROR_MESSAGE } from '@/utils/constants';
5 | import { threadService } from '@/services';
6 |
7 | /**
8 | * POST /api/threads
9 | */
10 | export const createThread = async (
11 | req: Request,
12 | res: Response,
13 | next: NextFunction,
14 | ): Promise => {
15 | const { userId, channelId, content, parentId } = req.body;
16 | if (verifyRequestData([userId, channelId, content]) && parentId !== undefined) {
17 | // userId, channelId, content, parentId 이상한값 예외처리 추후 추가
18 | try {
19 | const threadId = await threadService.createThread({ userId, channelId, content, parentId });
20 | res.status(201).json({ threadId });
21 | return;
22 | } catch (err) {
23 | next(err);
24 | }
25 | }
26 | res.status(400).json({ message: ERROR_MESSAGE.MISSING_REQUIRED_VALUES });
27 | };
28 |
29 | /**
30 | * GET /api/threads/channels/:channelId
31 | */
32 | export const getChannelThreads = async (
33 | req: Request,
34 | res: Response,
35 | next: NextFunction,
36 | ): Promise => {
37 | const { channelId } = req.params;
38 | const { nextThreadId } = req.query;
39 | if (Number.isNaN(Number(channelId))) {
40 | next({ message: ERROR_MESSAGE.WRONG_PARAMS, status: 400 });
41 | return;
42 | }
43 |
44 | if (nextThreadId && Number.isNaN(+nextThreadId)) {
45 | next({ message: ERROR_MESSAGE.WRONG_PARAMS, status: 400 });
46 | return;
47 | }
48 |
49 | try {
50 | const limit = 15;
51 | const options = { channelId: +channelId, limit };
52 | if (nextThreadId) {
53 | Object.assign(options, { nextThreadId: +nextThreadId });
54 | }
55 | const [threadList] = await threadModel.getThreadListByLimit({ ...options });
56 | const threadListLength = threadList.length;
57 | const nextId =
58 | threadListLength && threadListLength === limit ? threadList[threadListLength - 1].id : -1;
59 | res.json({ threadList: threadList.reverse(), nextThreadId: nextId });
60 | } catch (err) {
61 | next(err);
62 | }
63 | };
64 |
--------------------------------------------------------------------------------
/server/src/routes/api/users/[userId]/index.ts:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import * as userIdController from './userId.controller';
3 |
4 | const router = express.Router({ mergeParams: true });
5 |
6 | router.use(userIdController.checkUserIdParam);
7 | router.get('/', userIdController.getUser);
8 | router.post('/', userIdController.modifyUser);
9 | router.delete('/', userIdController.deleteUser);
10 | router.post('/last-channel', userIdController.modifyLastChannel);
11 | router.get('/channels', userIdController.getJoinedChannels);
12 | router.get('/channels/unsubscribed', userIdController.getNotJoinedChannels);
13 |
14 | export default router;
15 |
--------------------------------------------------------------------------------
/server/src/routes/api/users/[userId]/userId.yaml:
--------------------------------------------------------------------------------
1 | paths:
2 | /api/users/{userId}:
3 | get:
4 | tags:
5 | - description: 유저
6 | summary: 특정 유저 정보 가져오기
7 | parameters:
8 | - name: userId
9 | in: path
10 | description: 유저 아이디
11 | required: true
12 | type: string
13 | responses:
14 | 200:
15 | description: OK
16 | content:
17 | application/json:
18 | schema:
19 | $ref: '#/components/schemas/User'
20 | default:
21 | description: Error
22 | content:
23 | application/json:
24 | schema:
25 | $ref: '#/components/schemas/ErrorResponse'
26 | post:
27 | tags:
28 | - description: 유저
29 | summary: 특정 유저 수정하기
30 | parameters:
31 | - name: userId
32 | in: path
33 | description: 유저 아이디
34 | required: true
35 | type: string
36 | requestBody:
37 | content:
38 | application/json:
39 | schema:
40 | type: object
41 | required: true
42 | properties:
43 | displayName:
44 | type: string
45 | phoneNumber:
46 | type: string
47 | responses:
48 | 200:
49 | description: OK
50 | default:
51 | description: Error
52 | content:
53 | application/json:
54 | schema:
55 | $ref: '#/components/schemas/ErrorResponse'
56 | delete:
57 | tags:
58 | - description: 유저
59 | summary: 특정 유저 삭제하기
60 | parameters:
61 | - name: userId
62 | in: path
63 | description: 유저 아이디
64 | required: true
65 | type: string
66 | responses:
67 | 200:
68 | description: OK
69 | default:
70 | description: Error
71 | content:
72 | application/json:
73 | schema:
74 | $ref: '#/components/schemas/ErrorResponse'
75 |
--------------------------------------------------------------------------------
/server/src/routes/api/users/index.ts:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import * as usersController from './users.controller';
3 | import userIdRouter from './[userId]';
4 |
5 | const router = express.Router();
6 |
7 | router.get('/', usersController.getUsers);
8 | router.post('/search', usersController.searchUsers);
9 | router.use('/:userId', userIdRouter);
10 |
11 | export default router;
12 |
--------------------------------------------------------------------------------
/server/src/routes/api/users/users.controller.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response, NextFunction } from 'express';
2 | import { channelModel, userModel } from '@/models';
3 | import { verifyRequestData } from '@/utils/utils';
4 |
5 | /**
6 | * GET /api/users
7 | */
8 | export const getUsers = async (req: Request, res: Response, next: NextFunction): Promise => {
9 | try {
10 | const [users] = await userModel.getUsers();
11 | res.status(200).json({ users });
12 | } catch (err) {
13 | next(err);
14 | }
15 | };
16 |
17 | /**
18 | * GET /api/users/search
19 | */
20 | export const searchUsers = async (
21 | req: Request,
22 | res: Response,
23 | next: NextFunction,
24 | ): Promise => {
25 | try {
26 | const { displayName, channelId, isDM } = req.body;
27 | if (verifyRequestData([displayName, channelId])) {
28 | const [users] = await userModel.searchUsers({ displayName, channelId, isDM });
29 | res.status(200).json({ users });
30 | return;
31 | }
32 | } catch (err) {
33 | next(err);
34 | }
35 | };
36 |
--------------------------------------------------------------------------------
/server/src/routes/api/users/users.yaml:
--------------------------------------------------------------------------------
1 | components:
2 | schemas:
3 | User:
4 | type: object
5 | properties:
6 | id:
7 | type: number
8 | email:
9 | type: string
10 | image:
11 | type: string
12 | nullable: true
13 | displayName:
14 | type: string
15 | phoneNumber:
16 | type: string
17 | nullable: true
18 |
19 | paths:
20 | /api/users:
21 | get:
22 | tags:
23 | - description: 유저
24 | summary: 유저 리스트를 가져온다.
25 | responses:
26 | 200:
27 | description: 유저 리스트를 반환
28 | content:
29 | application/json:
30 | schema:
31 | type: array
32 | items:
33 | $ref: '#/components/schemas/User'
34 | default:
35 | description: Error
36 | content:
37 | application/json:
38 | schema:
39 | $ref: '#/components/schemas/ErrorResponse'
40 |
--------------------------------------------------------------------------------
/server/src/routes/swagger.yaml:
--------------------------------------------------------------------------------
1 | components:
2 | schemas:
3 | ErrorResponse:
4 | type: object
5 | properties:
6 | message:
7 | type: string
8 | nullable: true
9 |
--------------------------------------------------------------------------------
/server/src/services/channel.service.ts:
--------------------------------------------------------------------------------
1 | import { channelModel } from '@/models';
2 | import { join } from 'path';
3 |
4 | interface JoinedUser {
5 | userId: number;
6 | displayName: string;
7 | image: string;
8 | }
9 |
10 | interface Channel {
11 | id?: number; // 채널 생성시 아이디가 없음
12 | ownerId: number;
13 | name: string;
14 | channelType: number;
15 | topic: string;
16 | isPublic: number;
17 | memberCount: number;
18 | description: string;
19 | createdAt?: string;
20 | updatedAt?: string;
21 | unreadMessage?: boolean;
22 | }
23 |
24 | interface updateChannelTopicParams {
25 | channelId: number;
26 | topic: string;
27 | }
28 |
29 | interface updateChannelUsersParams {
30 | users: JoinedUser[];
31 | channel: Channel;
32 | }
33 |
34 | interface makeDMParams {
35 | ownerId: number;
36 | name: string;
37 | memberCount: number;
38 | isPublic: number;
39 | description: string;
40 | channelType: number;
41 | users: JoinedUser[];
42 | }
43 |
44 | export const channelService = {
45 | async updateChannelTopic({ channelId, topic }: updateChannelTopicParams): Promise {
46 | await channelModel.modifyTopic({ channelId, topic });
47 | },
48 | async updateChannelUsers({ users, channel }: updateChannelUsersParams): Promise {
49 | const selectedUsers: [number[]] = users.reduce((acc: any, cur: JoinedUser) => {
50 | acc.push([cur.userId, channel.id]);
51 | return acc;
52 | }, []);
53 |
54 | await channelModel.joinChannel({
55 | selectedUsers,
56 | prevMemberCount: channel.memberCount,
57 | channelId: channel.id,
58 | });
59 | const [joinedUsers] = await channelModel.getChannelUser({
60 | channelId: channel.id,
61 | });
62 |
63 | return joinedUsers;
64 | },
65 | async makeDM({
66 | ownerId,
67 | name,
68 | memberCount,
69 | isPublic,
70 | description,
71 | channelType,
72 | users,
73 | }: makeDMParams): Promise {
74 | const [newChannel] = await channelModel.createChannel({
75 | ownerId,
76 | name,
77 | memberCount,
78 | isPublic,
79 | description,
80 | channelType,
81 | });
82 |
83 | const [joinedUsers] = await channelModel.getChannelUser({
84 | channelId: newChannel.insertId,
85 | });
86 |
87 | const selectedUsers: [number[]] = users.reduce((acc: any, cur) => {
88 | acc.push([cur.userId, newChannel.insertId]);
89 | return acc;
90 | }, []);
91 |
92 | await channelModel.joinChannel({
93 | channelId: newChannel.insertId,
94 | prevMemberCount: joinedUsers.length,
95 | selectedUsers,
96 | });
97 | },
98 | };
99 |
--------------------------------------------------------------------------------
/server/src/services/emoji.service.ts:
--------------------------------------------------------------------------------
1 | import pool from '@/db';
2 | import { GET_EMOJI_OF_THREAD_SQL, UPDATE_EMOJIES_OF_THREAD_SQL } from '@/utils/constants';
3 |
4 | interface EmojiOfThread {
5 | id: number;
6 | userList: number[];
7 | }
8 |
9 | interface UpdateEmojiProps {
10 | emojiId: number;
11 | userId: number;
12 | threadId: number;
13 | }
14 |
15 | export const emojiService = {
16 | async updateEmoji({
17 | emojiId,
18 | userId,
19 | threadId,
20 | }: UpdateEmojiProps): Promise<{ emojisOfThread?: EmojiOfThread[]; err?: Error }> {
21 | const conn = await pool.getConnection();
22 | try {
23 | await conn.beginTransaction();
24 |
25 | const [[{ emoji }]]: any[] = await conn.execute(GET_EMOJI_OF_THREAD_SQL, [threadId]);
26 | let emojisOfThread: EmojiOfThread[] = emoji;
27 |
28 | const emojiIdx = emojisOfThread.findIndex(
29 | (emojiOfThread: EmojiOfThread) => +emojiOfThread.id === emojiId,
30 | );
31 |
32 | // 현재 emoji가 없는 경우 + 전체 이모지가 없을때
33 | if (emojiIdx === -1) {
34 | const newEmoji = { id: emojiId, userList: [userId] };
35 | emojisOfThread = [...emojisOfThread, newEmoji];
36 | }
37 |
38 | // emoji가 있는데, 해당 emoji의 userList에 userId가 있으면 유저 삭제, 없으면 유저 추가.
39 | if (emojiIdx !== -1 && emojisOfThread[emojiIdx]) {
40 | const targetUserList = emojisOfThread[emojiIdx].userList;
41 | const userIdx = targetUserList.findIndex((id) => id === userId);
42 |
43 | // 유저가 있으면 삭제
44 | if (userIdx !== -1) {
45 | targetUserList.splice(userIdx, 1);
46 | if (targetUserList.length === 0) {
47 | emojisOfThread.splice(emojiIdx, 1);
48 | }
49 | }
50 |
51 | // 유저가 없으면 추가
52 | if (userIdx === -1) {
53 | const { userList } = emojisOfThread[emojiIdx];
54 | userList.push(userId);
55 | const newEmojiState = { id: emojiId, userList };
56 | emojisOfThread[emojiIdx] = newEmojiState;
57 | }
58 | }
59 |
60 | const emojisOfThreadJson = JSON.stringify(emojisOfThread);
61 | const sql = conn.format(UPDATE_EMOJIES_OF_THREAD_SQL, [emojisOfThreadJson, threadId]);
62 | await conn.execute(sql);
63 | await conn.commit();
64 | return { emojisOfThread };
65 | } catch (err) {
66 | conn.rollback();
67 | console.error(err);
68 | return { err };
69 | } finally {
70 | conn.release();
71 | }
72 | },
73 | };
74 |
--------------------------------------------------------------------------------
/server/src/services/index.ts:
--------------------------------------------------------------------------------
1 | export * from './thread.service';
2 | export * from './emoji.service';
3 |
--------------------------------------------------------------------------------
/server/src/types/index.ts:
--------------------------------------------------------------------------------
1 | import { FieldPacket, OkPacket, ResultSetHeader, RowDataPacket } from 'mysql2';
2 |
3 | export interface Error {
4 | status?: number;
5 | message?: string;
6 | }
7 |
8 | export interface EmojiOfThread {
9 | id: number;
10 | userList: number[];
11 | }
12 |
13 | export interface Thread {
14 | [key: string]: any;
15 | id: number;
16 | userId: number;
17 | channelId: number;
18 | parentId: number | null;
19 | content: string;
20 | url: string;
21 | isEdited: number;
22 | isPinned: number;
23 | isDeleted: number;
24 | createdAt: string;
25 | emoji: EmojiOfThread[];
26 | subCount: number;
27 | subThreadUserId1: number | null;
28 | subThreadUserId2: number | null;
29 | subThreadUserId3: number | null;
30 | email: string;
31 | displayName: string;
32 | phoneNumber: string | null;
33 | image: string;
34 | }
35 |
36 | export interface User {
37 | id: number;
38 | pw: string;
39 | email: string;
40 | displayName: string;
41 | phoneNumber: string | null;
42 | image: string;
43 | lastChannelId: number | null;
44 | }
45 |
46 | export type AuthToken = 'ACCESS' | 'REFRESH';
47 |
48 | export type PoolReturnType = Promise<
49 | [RowDataPacket[][] | RowDataPacket[] | OkPacket | OkPacket[] | ResultSetHeader, FieldPacket[]]
50 | >;
51 |
52 | export interface Model {
53 | [key: string]: (param?: any) => any;
54 | }
55 |
--------------------------------------------------------------------------------
/server/src/utils/utils.ts:
--------------------------------------------------------------------------------
1 | import jwt from 'jsonwebtoken';
2 | import config from '@/config';
3 | import { AuthToken } from '@/types';
4 | import { TOKEN_TYPE } from '@/utils/constants';
5 | import crypto from 'crypto';
6 | import nodemailer from 'nodemailer';
7 | import smtpTransport from 'nodemailer-smtp-transport';
8 |
9 | const CIPHER_ALGORITHM = process.env.CIPHER_ALGORITHM as string;
10 | const CIPHER_KEY = process.env.CIPHER_KEY as string;
11 | const IV = process.env.IV as string;
12 |
13 | export const verifyRequestData = (arr: any[]): boolean =>
14 | arr.every((e) => {
15 | return e !== undefined && e !== null;
16 | });
17 |
18 | export const verifyToken = (authToken: string, type: AuthToken): Promise => {
19 | return new Promise((resolve, reject) => {
20 | jwt.verify(
21 | authToken,
22 | type === TOKEN_TYPE.ACCESS ? config.jwtSecret : config.jwtRefreshSecret,
23 | (err, decoded) => {
24 | if (err) {
25 | reject(err);
26 | return;
27 | }
28 |
29 | if (decoded) {
30 | resolve(decoded);
31 | }
32 | },
33 | );
34 | });
35 | };
36 |
37 | export const getRandomString = (len: number): string => {
38 | const str = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
39 | return Array(len)
40 | .fill(1)
41 | .reduce((acc) => acc + str.charAt(Math.floor(Math.random() * str.length)), '');
42 | };
43 |
44 | export const encrypt = (text: string): string => {
45 | const cipher = crypto.createCipheriv(CIPHER_ALGORITHM, CIPHER_KEY, IV);
46 | return cipher.update(text, 'utf8', 'hex') + cipher.final('hex');
47 | };
48 |
49 | export const decrypt = (text: string): string => {
50 | const decipher = crypto.createDecipheriv(CIPHER_ALGORITHM, CIPHER_KEY, IV);
51 | return decipher.update(text, 'hex', 'utf8') + decipher.final('utf8');
52 | };
53 |
54 | export const sendEmail = (targetEmail: string, content: string): Promise => {
55 | return new Promise((resolve, reject) => {
56 | const transporter = nodemailer.createTransport(
57 | smtpTransport({
58 | tls: {
59 | rejectUnauthorized: false,
60 | },
61 | service: 'naver',
62 | auth: {
63 | user: config.NODE_MAILER.email,
64 | pass: config.NODE_MAILER.pw,
65 | },
66 | }),
67 | );
68 | const mailOptions = {
69 | from: `Slack_06<${config.NODE_MAILER.email}>`,
70 | to: targetEmail,
71 | subject: `[Slack_06] ${content}`,
72 | text: content,
73 | };
74 | transporter.sendMail(mailOptions, (err, info) => {
75 | if (err) {
76 | reject(err);
77 | return;
78 | }
79 | transporter.close();
80 | resolve();
81 | });
82 | });
83 | };
84 |
--------------------------------------------------------------------------------
/server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowJs": true,
4 | "checkJs": true,
5 | "target": "es5",
6 | "lib": ["ES6"],
7 | "noImplicitAny": true,
8 | "strict": true,
9 | "sourceMap": true,
10 | "rootDir": "./src",
11 | "outDir": "./dist",
12 | "moduleResolution": "node",
13 | "esModuleInterop": true,
14 | "allowSyntheticDefaultImports": true,
15 | "skipLibCheck": true,
16 | "forceConsistentCasingInFileNames": true,
17 | "resolveJsonModule": true,
18 | "baseUrl": "./src",
19 | "paths": {
20 | "@/*": ["./*"]
21 | }
22 | },
23 | "include": ["./src/**/*"],
24 | "exculde": ["node_modules"]
25 | }
26 |
--------------------------------------------------------------------------------