├── .github
├── ISSUE_TEMPLATE
│ ├── feat--제목.md
│ └── feature_request.md
├── PULL_REQUEST_TEMPLATE.md
└── workflows
│ ├── be-ci.yml
│ ├── deploy.yml
│ └── slack-notify.yml
├── README.md
├── client
├── .eslintrc.json
├── .gitignore
├── .prettierrc.json
├── README.md
├── package-lock.json
├── package.json
├── public
│ ├── browserconfig.xml
│ ├── dummy-hgseo.json
│ ├── dummy-jiychoi.json
│ ├── dummy-limited-hyeon.json
│ ├── dummy-mincho.json
│ ├── dummyData.json
│ ├── earlybird.gif
│ ├── earlybird.png
│ ├── favicons
│ │ ├── android-icon-144x144.png
│ │ ├── android-icon-192x192.png
│ │ ├── android-icon-36x36.png
│ │ ├── android-icon-48x48.png
│ │ ├── android-icon-72x72.png
│ │ ├── android-icon-96x96.png
│ │ ├── apple-icon-114x114.png
│ │ ├── apple-icon-120x120.png
│ │ ├── apple-icon-144x144.png
│ │ ├── apple-icon-152x152.png
│ │ ├── apple-icon-180x180.png
│ │ ├── apple-icon-57x57.png
│ │ ├── apple-icon-60x60.png
│ │ ├── apple-icon-72x72.png
│ │ ├── apple-icon-76x76.png
│ │ ├── apple-icon-precomposed.png
│ │ ├── apple-icon.png
│ │ ├── favicon-16x16.png
│ │ ├── favicon-32x32.png
│ │ ├── favicon-96x96.png
│ │ ├── favicon.ico
│ │ ├── ms-icon-144x144.png
│ │ ├── ms-icon-150x150.png
│ │ ├── ms-icon-310x310.png
│ │ └── ms-icon-70x70.png
│ ├── index.html
│ ├── loginBG.jpg
│ ├── logo.png
│ └── manifest.json
├── src
│ ├── App.tsx
│ ├── assets
│ │ └── svgs
│ │ │ ├── arrowDownIcon.svg
│ │ │ ├── checkIcon.svg
│ │ │ ├── cryIcon.svg
│ │ │ ├── editIcon.svg
│ │ │ ├── emptyHeartIcon.svg
│ │ │ ├── errorIcon.svg
│ │ │ ├── filledHeartIcon.svg
│ │ │ ├── filterIcon.svg
│ │ │ ├── githubIcon.svg
│ │ │ ├── googleIcon.svg
│ │ │ ├── index.ts
│ │ │ ├── labelIcon.svg
│ │ │ ├── messageIcon.svg
│ │ │ ├── questionIcon.svg
│ │ │ ├── saveIcon.svg
│ │ │ ├── searchIcon.svg
│ │ │ └── xIcon.svg
│ ├── common
│ │ ├── Button.tsx
│ │ ├── CodeBox
│ │ │ ├── index.tsx
│ │ │ └── styles.ts
│ │ ├── CommonLayout
│ │ │ ├── Header.styles.ts
│ │ │ ├── Header.tsx
│ │ │ ├── index.tsx
│ │ │ └── styles.ts
│ │ ├── DropdownInput
│ │ │ ├── DropdownInput.styles.ts
│ │ │ ├── InterestInput.tsx
│ │ │ ├── InterestsBox.styles.ts
│ │ │ ├── InterestsBox.tsx
│ │ │ ├── SelectedItems.styles.ts
│ │ │ ├── SelectedItems.tsx
│ │ │ ├── TechStackBox.styles.ts
│ │ │ ├── TechStackBox.tsx
│ │ │ ├── TechStackCheckbox.tsx
│ │ │ ├── TechStackInput.tsx
│ │ │ └── index.ts
│ │ ├── LoadingFallback.tsx
│ │ ├── LoginLayout.tsx
│ │ ├── MiniNavBar.tsx
│ │ ├── NavSubtitle.tsx
│ │ ├── Tooltip.tsx
│ │ └── index.ts
│ ├── hooks
│ │ ├── index.ts
│ │ ├── useCheckLogin.ts
│ │ ├── useClickOutside.ts
│ │ ├── useLoadSettings.ts
│ │ ├── useSetSSE.ts
│ │ ├── useShowTooltip.ts
│ │ └── useValidateUsername.ts
│ ├── index.tsx
│ ├── logo.svg
│ ├── pages
│ │ ├── DetailPage
│ │ │ ├── DetailInner.tsx
│ │ │ ├── EditModeContainer
│ │ │ │ ├── BottomProfileEditor.styles.ts
│ │ │ │ ├── BottomProfileEditor.tsx
│ │ │ │ ├── CodeEditor.styles.ts
│ │ │ │ ├── CodeEditor.tsx
│ │ │ │ ├── NavBarInner.styles.ts
│ │ │ │ ├── NavBarInner.tsx
│ │ │ │ ├── TopProfileEditor.styles.ts
│ │ │ │ ├── TopProfileEditor.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ ├── styles.ts
│ │ │ │ └── useSetProfileEditor.ts
│ │ │ ├── ViewModeContainer
│ │ │ │ ├── BottomProfileBox.styles.ts
│ │ │ │ ├── BottomProfileBox.tsx
│ │ │ │ ├── InterestBadge.styles.ts
│ │ │ │ ├── InterestBadge.tsx
│ │ │ │ ├── TechStackBadge.styles.ts
│ │ │ │ ├── TechStackBadge.tsx
│ │ │ │ ├── TopProfileBox.styles.ts
│ │ │ │ ├── TopProfileBox.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ └── styles.ts
│ │ │ ├── index.tsx
│ │ │ └── services
│ │ │ │ ├── fetchEditUserProfile.ts
│ │ │ │ ├── fetchUserData.ts
│ │ │ │ └── index.ts
│ │ ├── LoginPage
│ │ │ ├── LoginButtonComponent.styles.ts
│ │ │ ├── LoginButtonComponent.tsx
│ │ │ └── index.tsx
│ │ ├── MainPage
│ │ │ ├── InterestTag.styles.ts
│ │ │ ├── InterestTag.tsx
│ │ │ ├── NavBar.styles.ts
│ │ │ ├── Profile.styles.ts
│ │ │ ├── Profile.tsx
│ │ │ ├── ProfileList.tsx
│ │ │ ├── fetchFilteredData.ts
│ │ │ ├── index.tsx
│ │ │ ├── styles.ts
│ │ │ └── useSetMainPageData.ts
│ │ ├── MessagePage
│ │ │ ├── MessageDetail
│ │ │ │ ├── MessageDetailInner.tsx
│ │ │ │ ├── MessageElement.styles.ts
│ │ │ │ ├── MessageElement.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ └── styles.ts
│ │ │ ├── MessageList.styles.ts
│ │ │ ├── MessageList.tsx
│ │ │ ├── MessageTopBar.tsx
│ │ │ ├── hooks
│ │ │ │ ├── index.ts
│ │ │ │ ├── useManageMessageData.ts
│ │ │ │ └── useScrollBottom.ts
│ │ │ ├── index.tsx
│ │ │ ├── services
│ │ │ │ ├── fetchMessageDetail.ts
│ │ │ │ ├── fetchMessageList.ts
│ │ │ │ ├── fetchSendMessage.ts
│ │ │ │ └── index.ts
│ │ │ └── styles.ts
│ │ ├── RegisterPage
│ │ │ ├── fetchRequestRegistration.ts
│ │ │ ├── index.tsx
│ │ │ ├── styles.ts
│ │ │ ├── usernameInput.styles.ts
│ │ │ └── usernameInput.tsx
│ │ ├── SettingsPage
│ │ │ ├── CodeBoxSelector.styles.ts
│ │ │ ├── CodeBoxSizeSelector.tsx
│ │ │ ├── CodeBoxThemeSelector.tsx
│ │ │ ├── MiniCodeBox.tsx
│ │ │ ├── index.tsx
│ │ │ └── styles.ts
│ │ └── index.ts
│ ├── react-app-env.d.ts
│ ├── reset.css
│ ├── services
│ │ ├── fetchCheckLogin.ts
│ │ ├── fetchLogout.ts
│ │ ├── fetchSendLikeToServer.ts
│ │ ├── fetchUsernameServerValidation.ts
│ │ └── index.ts
│ ├── store
│ │ ├── currentUserState.ts
│ │ ├── index.ts
│ │ ├── isNewMessageState.ts
│ │ ├── newMessageState.ts
│ │ └── settingsState.ts
│ ├── styles
│ │ ├── colors.ts
│ │ ├── mediaQuery.ts
│ │ └── sizes.ts
│ ├── types
│ │ ├── auth.d.ts
│ │ ├── message.d.ts
│ │ ├── profile.d.ts
│ │ └── settings.d.ts
│ └── utils
│ │ ├── constants.ts
│ │ ├── fetchUtils.ts
│ │ ├── storage.ts
│ │ └── usernameValidation.ts
└── tsconfig.json
└── server
├── .eslintrc.js
├── .gitignore
├── .prettierrc
├── README.md
├── ecosystem.config.js
├── nest-cli.json
├── package-lock.json
├── package.json
├── src
├── app.module.ts
├── auth
│ ├── auth.controller.spec.ts
│ ├── auth.controller.ts
│ ├── auth.integration.spec.ts
│ ├── auth.module.ts
│ ├── auth.service.spec.ts
│ └── auth.service.ts
├── common
│ ├── base-entity.ts
│ ├── d.ts
│ ├── enum.ts
│ ├── http-execption-filter.ts
│ └── response
│ │ ├── error-response.ts
│ │ ├── index.ts
│ │ └── success-response.ts
├── like
│ ├── dto
│ │ ├── add-like.dto.ts
│ │ └── delete-like.dto.ts
│ ├── entities
│ │ └── like.entity.ts
│ ├── like.controller.spec.ts
│ ├── like.controller.ts
│ ├── like.integration.spec.ts
│ ├── like.module.ts
│ ├── like.repository.spec.ts
│ ├── like.repository.ts
│ ├── like.service.spec.ts
│ └── like.service.ts
├── main.ts
├── message
│ ├── dto
│ │ └── send-message.dto.ts
│ ├── entities
│ │ ├── content.entity.ts
│ │ └── message.entity.ts
│ ├── message.controller.spec.ts
│ ├── message.controller.ts
│ ├── message.integration.spec.ts
│ ├── message.module.ts
│ ├── message.repository.spec.ts
│ ├── message.repository.ts
│ ├── message.service.spec.ts
│ └── message.service.ts
├── test
│ ├── mongo.ts
│ └── stub.ts
└── user
│ ├── dto
│ ├── create-user.dto.ts
│ ├── find-user.dto.ts
│ ├── page-user.dto.ts
│ ├── pagination.ts
│ └── update-user.dto.ts
│ ├── entities
│ └── user.entity.ts
│ ├── user.controller.spec.ts
│ ├── user.controller.ts
│ ├── user.integration.spec.ts
│ ├── user.module.ts
│ ├── user.repository.spec.ts
│ ├── user.repository.ts
│ ├── user.service.spec.ts
│ └── user.service.ts
├── test
├── app.e2e-spec.ts
└── jest-e2e.json
├── tsconfig.build.json
└── tsconfig.json
/.github/ISSUE_TEMPLATE/feat--제목.md:
--------------------------------------------------------------------------------
1 | ## 💁 설명
2 |
3 | - Github 이슈 설명
4 |
5 | ## 📑 체크리스트
6 |
7 | > 구현해야하는 이슈 체크리스트
8 |
9 | - [ ] 체크 사항 1
10 | - [ ] 체크 사항 2
11 | - [ ] 체크 사항 3
12 | - [ ] 체크 사항 4
13 |
14 | ## 🚧 주의 사항
15 |
16 | > 이슈를 구현할 때 유의깊게 살펴볼 사항
17 |
18 | - 주의 사항 1
19 | - 주의 사항 2
20 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | ## 💁 설명
11 |
12 | - Github 이슈 설명
13 |
14 | ## 📑 체크리스트
15 |
16 | > 구현해야하는 이슈 체크리스트
17 |
18 | - [ ] 체크 사항 1
19 | - [ ] 체크 사항 2
20 | - [ ] 체크 사항 3
21 | - [ ] 체크 사항 4
22 |
23 | ## 🚧 주의 사항
24 |
25 | > 이슈를 구현할 때 유의깊게 살펴볼 사항
26 |
27 | - 주의 사항 1
28 | - 주의 사항 2
29 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | ## 📕 제목
2 |
3 | PR 제목
4 | 관련이슈 #00, .....
5 |
6 | ## 📗 작업 내용
7 |
8 | > 구현 내용 및 작업 했던 내역
9 |
10 | - [x] 작업내용1
11 | - [x] 작업내용2
12 | - [x] 작업내용3
13 |
14 | ## 📘 PR 특이 사항
15 |
16 | > PR을 볼 때 주의깊게 봐야하거나 말하고 싶은 점
17 |
18 | - 특이사항1
19 | - 특이사항2
20 |
--------------------------------------------------------------------------------
/.github/workflows/be-ci.yml:
--------------------------------------------------------------------------------
1 | name: Backend CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - "**/be-**"
7 | pull_request:
8 | branches:
9 | - "**/be-**"
10 |
11 | defaults:
12 | run:
13 | working-directory: ./server
14 |
15 | jobs:
16 | node_CI:
17 | runs-on: ubuntu-latest
18 |
19 | strategy:
20 | matrix:
21 | node-version: [16.x]
22 |
23 | steps:
24 | - name: Checkout source code
25 | uses: actions/checkout@v3
26 |
27 | - name: Set up Node.js
28 | uses: actions/setup-node@v3
29 | with:
30 | node-version: ${{ matrix.node-version }}
31 |
32 | - name: Install dependencies
33 | run: npm install
34 |
35 | - name: Check prettier, eslint & Test
36 | run: npm run ci
37 |
--------------------------------------------------------------------------------
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | name: App install and start
2 |
3 | concurrency:
4 | group: production
5 | cancel-in-progress: true
6 |
7 | on:
8 | push:
9 | branches: [dev]
10 | workflow_dispatch:
11 | jobs:
12 | build:
13 | runs-on: [self-hosted]
14 |
15 | steps:
16 | - uses: actions/checkout@v2
17 | with:
18 | clean: false
19 |
20 | - name: Use Node.js 16.x
21 | uses: actions/setup-node@v1
22 | with:
23 | node-version: 16.x
24 |
25 | - name: Cache dependencies
26 | id: cache
27 | uses: actions/cache@v3
28 | with:
29 | # cache의 대상을 정합니다. npm에서 의존성이 설치되는 디렉터리인 node_modules를 대상으로 합니다.
30 | path: "**/node_modules"
31 | # cache를 무효화하를 결정하는 기준은 의존성이 변경되면 함께 변경되는 파일인 package-lock.json을 기준으로 합니다.
32 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
33 | # key가 유효하지 않은 경우 runner의 운영체제 값과 node라는 suffix를 key로 복구합니다.
34 | # 결과적으로 package-lock.json이 변경되지 않았다면 캐싱된 node_modules를 사용합니다.
35 | # 만약 복구될 캐시가 없다면 아래에서 사용할 cache-hit는 false가 됩니다.
36 | restore-keys: |
37 | ${{ runner.os }}-node-
38 |
39 | - name: Stop old server (ignore error)
40 | run: |
41 | pm2 delete scopa || true
42 |
43 | - name: Remove old server in ~/web25-SCOPA (ignore error)
44 | run: |
45 | rm -rf ~/web25-SCOPA || true
46 |
47 | - name: Copy new server to ~/web25-SCOPA
48 | run: |
49 | mkdir -p ~/web25-SCOPA
50 | cp -R ./ ~/web25-SCOPA
51 |
52 | - name: Copy env file
53 | run: |
54 | cp ~/env/server/.env ~/web25-SCOPA/server
55 | cp ~/env/client/.env ~/web25-SCOPA/client
56 |
57 | - name: Install dependency
58 | if: steps.cache.outputs.cache-hit != 'true'
59 | run: |
60 | cd ~/web25-SCOPA/client
61 | npm install
62 | cd ~/web25-SCOPA/server
63 | npm install
64 |
65 | - name: Run frontend
66 | run: |
67 | cd ~/web25-SCOPA/client
68 | CI='' npm run build && service nginx restart
69 |
70 | - name: Run backend
71 | run: |
72 | cd ~/web25-SCOPA/server
73 | npm run build && npm run deploy
74 |
--------------------------------------------------------------------------------
/.github/workflows/slack-notify.yml:
--------------------------------------------------------------------------------
1 | name: slack-notify
2 |
3 | on:
4 | pull_request:
5 | branches: [dev]
6 |
7 | jobs:
8 | build:
9 | runs-on: ubuntu-latest
10 |
11 | steps:
12 | - name: action-slack
13 | uses: 8398a7/action-slack@v3
14 | with:
15 | status: ${{ job.status }}
16 | author_name: ${{ github.event.pull_request.html_url }} # default: 8398a7@action-slack
17 | fields: pullRequest,commit,author,ref
18 | env:
19 | SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} # required
20 | if: success()
21 |
--------------------------------------------------------------------------------
/client/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
25 | .env
26 |
--------------------------------------------------------------------------------
/client/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "jsxSingleQuote": true,
3 | "semi": true,
4 | "printWidth": 120,
5 | "proseWrap": "never",
6 | "singleQuote": true,
7 | "htmlWhitespaceSensitivity": "css",
8 | "endOfLine": "lf"
9 | }
10 |
--------------------------------------------------------------------------------
/client/README.md:
--------------------------------------------------------------------------------
1 | # SCOPA Client
2 |
--------------------------------------------------------------------------------
/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "client",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@emotion/react": "^11.10.5",
7 | "@emotion/styled": "^11.10.5",
8 | "@monaco-editor/react": "^4.4.6",
9 | "@testing-library/jest-dom": "^5.16.5",
10 | "@testing-library/react": "^13.4.0",
11 | "@testing-library/user-event": "^13.5.0",
12 | "@types/jest": "^27.5.2",
13 | "@types/node": "^16.18.3",
14 | "@types/react": "^18.0.25",
15 | "@types/react-dom": "^18.0.8",
16 | "monaco-editor": "^0.34.1",
17 | "react": "^18.2.0",
18 | "react-dom": "^18.2.0",
19 | "react-error-boundary": "^3.1.4",
20 | "react-js-pagination": "^3.0.3",
21 | "react-router-dom": "^6.4.3",
22 | "react-scripts": "5.0.1",
23 | "react-syntax-highlighter": "^15.5.0",
24 | "recoil": "^0.7.6",
25 | "typescript": "^4.8.4",
26 | "web-vitals": "^2.1.4"
27 | },
28 | "scripts": {
29 | "start": "react-scripts start",
30 | "build": "react-scripts build",
31 | "test": "react-scripts test",
32 | "eject": "react-scripts eject"
33 | },
34 | "eslintConfig": {
35 | "extends": [
36 | "react-app",
37 | "react-app/jest"
38 | ]
39 | },
40 | "browserslist": {
41 | "production": [
42 | ">0.2%",
43 | "not dead",
44 | "not op_mini all"
45 | ],
46 | "development": [
47 | "last 1 chrome version",
48 | "last 1 firefox version",
49 | "last 1 safari version"
50 | ]
51 | },
52 | "devDependencies": {
53 | "@types/react-js-pagination": "^3.0.4",
54 | "@types/react-syntax-highlighter": "^15.5.5",
55 | "@typescript-eslint/eslint-plugin": "^5.42.1",
56 | "@typescript-eslint/parser": "^5.42.1",
57 | "eslint": "^8.27.0",
58 | "eslint-config-airbnb": "^19.0.4",
59 | "eslint-config-prettier": "^8.5.0",
60 | "eslint-config-react": "^1.1.7",
61 | "eslint-config-react-app": "^7.0.1",
62 | "eslint-plugin-flowtype": "^8.0.3",
63 | "eslint-plugin-import": "^2.26.0",
64 | "eslint-plugin-jsx-a11y": "^6.6.1",
65 | "eslint-plugin-prettier": "^4.2.1",
66 | "eslint-plugin-react": "^7.31.10",
67 | "eslint-plugin-react-hooks": "^4.6.0",
68 | "eslint-plugin-testing-library": "^5.9.1",
69 | "eslint-webpack-plugin": "^3.2.0",
70 | "prettier": "^2.7.1"
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/client/public/browserconfig.xml:
--------------------------------------------------------------------------------
1 |
2 | #ffffff
3 |
--------------------------------------------------------------------------------
/client/public/dummy-hgseo.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 10000,
3 | "message": "good",
4 | "data": [
5 | {
6 | "from": "hgseo",
7 | "content": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
8 | "time": "2022-12-08T05:11:12.123Z"
9 | }
10 | ]
11 | }
12 |
--------------------------------------------------------------------------------
/client/public/dummy-jiychoi.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 10000,
3 | "message": "good",
4 | "data": [
5 | { "from": "jiychoi", "content": "ㅁㄴㅇㅁㄴㅇㅁㄴ", "time": "2022-12-08T05:11:12.123Z" },
6 | { "from": "jiychoi", "content": "ㅁㄴㅇㅁㄴㅇㄴㅁㅇㄴㅁㅇㅁㄴㅇㅁㄴ", "time": "2022-12-08T05:15:12.123Z" },
7 | { "from": "jiychoi", "content": "ㅁㄴㅇㅁㄴㅇㄴㅁㅇㄴㅁㅇㅁㄴㅇㅁㄴ", "time": "2022-12-08T05:15:12.123Z" },
8 | { "from": "jiychoi", "content": "ㅁㄴㅇㅁㄴㅇㄴㅁㅇㄴㅁㅇㅁㄴㅇㅁㄴ", "time": "2022-12-08T05:15:12.123Z" },
9 | { "from": "jiychoi", "content": "ㅁㄴㅇㅁㄴㅇㄴㅁㅇㄴㅁㅇㅁㄴㅇㅁㄴ", "time": "2022-12-08T05:15:12.123Z" },
10 | { "from": "jiychoi", "content": "ㅁㄴㅇㅁㄴㅇㄴㅁㅇㄴㅁㅇㅁㄴㅇㅁㄴ", "time": "2022-12-08T05:15:12.123Z" },
11 | { "from": "jiychoi", "content": "ㅁㄴㅇㅁㄴㅇㄴㅁㅇㄴㅁㅇㅁㄴㅇㅁㄴ", "time": "2022-12-08T05:15:12.123Z" },
12 | { "from": "jiychoi", "content": "ㅁㄴㅇㅁㄴㅇㄴㅁㅇㄴㅁㅇㅁㄴㅇㅁㄴ", "time": "2022-12-08T05:15:12.123Z" },
13 | { "from": "jiychoi", "content": "ㅁㄴㅇㅁㄴㅇㄴㅁ3ㅇㄴㅁㅇㅁㄴㅇㅁㄴ", "time": "2022-12-08T05:15:12.123Z" },
14 | { "from": "jiychoi", "content": "ㅁㄴㅇㅁㄴㅇㄴㅁ2ㅇㄴㅁㅇㅁㄴㅇㅁㄴ", "time": "2022-12-08T05:15:12.123Z" },
15 | { "from": "jiychoi", "content": "ㅁㄴㅇㅁㄴㅇㄴㅁ1ㅇㄴㅁㅇㅁㄴㅇㅁㄴ", "time": "2022-12-08T05:15:12.123Z" },
16 | { "from": "jiychoi", "content": "ㅁㄴㅇㅁㄴㅇㄴㅁ3ㅇㄴㅁㅇㅁㄴㅇㅁㄴ", "time": "2022-12-08T05:15:12.123Z" },
17 | { "from": "jiychoi", "content": "ㅁㄴㅇㅁㄴㅇㄴㅁ2ㅇㄴㅁㅇㅁㄴㅇㅁㄴ", "time": "2022-12-08T05:15:12.123Z" },
18 | { "from": "jiychoi", "content": "ㅁㄴㅇㅁㄴㅇㄴㅁ1ㅇㄴㅁㅇㅁㄴㅇㅁㄴ", "time": "2022-12-08T05:15:12.123Z" },
19 | { "from": "jiychoi", "content": "ㅎㅇ", "time": "2022-12-08T05:15:12.123Z" }
20 | ]
21 | }
22 |
--------------------------------------------------------------------------------
/client/public/dummy-limited-hyeon.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 10000,
3 | "message": "good",
4 | "data": [{ "from": "limited-hyeon", "content": "🌚", "time": "2022-12-08T05:11:12.123Z" }]
5 | }
6 |
--------------------------------------------------------------------------------
/client/public/dummy-mincho.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 10000,
3 | "message": "good",
4 | "data": [
5 | { "from": "mincho", "content": "하이하이", "time": "2022-12-08T05:11:12.123Z" },
6 | { "from": "mincho", "content": "바이바이", "time": "2022-12-08T05:15:12.123Z" },
7 | { "from": "mincho", "content": "굿굿", "time": "2022-12-08T05:18:12.111Z" }
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------
/client/public/dummyData.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 10000,
3 | "message": "good",
4 | "data": {
5 | "messages": [
6 | { "with": "mincho", "lastCheckTime": "2022-12-08T08:38:05.617Z" },
7 | {
8 | "with": "jiychoi",
9 | "lastCheckTime": "2022-12-07T11:15:00.617Z"
10 | },
11 | {
12 | "with": "hgseo",
13 | "lastCheckTime": "2022-12-07T16:18:59.113Z"
14 | },
15 | {
16 | "with": "limited-hyeon",
17 | "lastCheckTime": "2022-12-06T00:09:12.694Z"
18 | }
19 | ],
20 | "lastPageConnectTime": "2022-12-08T15:15:11.555Z"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/client/public/earlybird.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2022/web25-SCOPA/25fae958d4148289d6208dd8677581fd892c8225/client/public/earlybird.gif
--------------------------------------------------------------------------------
/client/public/earlybird.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2022/web25-SCOPA/25fae958d4148289d6208dd8677581fd892c8225/client/public/earlybird.png
--------------------------------------------------------------------------------
/client/public/favicons/android-icon-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2022/web25-SCOPA/25fae958d4148289d6208dd8677581fd892c8225/client/public/favicons/android-icon-144x144.png
--------------------------------------------------------------------------------
/client/public/favicons/android-icon-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2022/web25-SCOPA/25fae958d4148289d6208dd8677581fd892c8225/client/public/favicons/android-icon-192x192.png
--------------------------------------------------------------------------------
/client/public/favicons/android-icon-36x36.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2022/web25-SCOPA/25fae958d4148289d6208dd8677581fd892c8225/client/public/favicons/android-icon-36x36.png
--------------------------------------------------------------------------------
/client/public/favicons/android-icon-48x48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2022/web25-SCOPA/25fae958d4148289d6208dd8677581fd892c8225/client/public/favicons/android-icon-48x48.png
--------------------------------------------------------------------------------
/client/public/favicons/android-icon-72x72.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2022/web25-SCOPA/25fae958d4148289d6208dd8677581fd892c8225/client/public/favicons/android-icon-72x72.png
--------------------------------------------------------------------------------
/client/public/favicons/android-icon-96x96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2022/web25-SCOPA/25fae958d4148289d6208dd8677581fd892c8225/client/public/favicons/android-icon-96x96.png
--------------------------------------------------------------------------------
/client/public/favicons/apple-icon-114x114.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2022/web25-SCOPA/25fae958d4148289d6208dd8677581fd892c8225/client/public/favicons/apple-icon-114x114.png
--------------------------------------------------------------------------------
/client/public/favicons/apple-icon-120x120.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2022/web25-SCOPA/25fae958d4148289d6208dd8677581fd892c8225/client/public/favicons/apple-icon-120x120.png
--------------------------------------------------------------------------------
/client/public/favicons/apple-icon-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2022/web25-SCOPA/25fae958d4148289d6208dd8677581fd892c8225/client/public/favicons/apple-icon-144x144.png
--------------------------------------------------------------------------------
/client/public/favicons/apple-icon-152x152.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2022/web25-SCOPA/25fae958d4148289d6208dd8677581fd892c8225/client/public/favicons/apple-icon-152x152.png
--------------------------------------------------------------------------------
/client/public/favicons/apple-icon-180x180.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2022/web25-SCOPA/25fae958d4148289d6208dd8677581fd892c8225/client/public/favicons/apple-icon-180x180.png
--------------------------------------------------------------------------------
/client/public/favicons/apple-icon-57x57.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2022/web25-SCOPA/25fae958d4148289d6208dd8677581fd892c8225/client/public/favicons/apple-icon-57x57.png
--------------------------------------------------------------------------------
/client/public/favicons/apple-icon-60x60.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2022/web25-SCOPA/25fae958d4148289d6208dd8677581fd892c8225/client/public/favicons/apple-icon-60x60.png
--------------------------------------------------------------------------------
/client/public/favicons/apple-icon-72x72.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2022/web25-SCOPA/25fae958d4148289d6208dd8677581fd892c8225/client/public/favicons/apple-icon-72x72.png
--------------------------------------------------------------------------------
/client/public/favicons/apple-icon-76x76.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2022/web25-SCOPA/25fae958d4148289d6208dd8677581fd892c8225/client/public/favicons/apple-icon-76x76.png
--------------------------------------------------------------------------------
/client/public/favicons/apple-icon-precomposed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2022/web25-SCOPA/25fae958d4148289d6208dd8677581fd892c8225/client/public/favicons/apple-icon-precomposed.png
--------------------------------------------------------------------------------
/client/public/favicons/apple-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2022/web25-SCOPA/25fae958d4148289d6208dd8677581fd892c8225/client/public/favicons/apple-icon.png
--------------------------------------------------------------------------------
/client/public/favicons/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2022/web25-SCOPA/25fae958d4148289d6208dd8677581fd892c8225/client/public/favicons/favicon-16x16.png
--------------------------------------------------------------------------------
/client/public/favicons/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2022/web25-SCOPA/25fae958d4148289d6208dd8677581fd892c8225/client/public/favicons/favicon-32x32.png
--------------------------------------------------------------------------------
/client/public/favicons/favicon-96x96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2022/web25-SCOPA/25fae958d4148289d6208dd8677581fd892c8225/client/public/favicons/favicon-96x96.png
--------------------------------------------------------------------------------
/client/public/favicons/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2022/web25-SCOPA/25fae958d4148289d6208dd8677581fd892c8225/client/public/favicons/favicon.ico
--------------------------------------------------------------------------------
/client/public/favicons/ms-icon-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2022/web25-SCOPA/25fae958d4148289d6208dd8677581fd892c8225/client/public/favicons/ms-icon-144x144.png
--------------------------------------------------------------------------------
/client/public/favicons/ms-icon-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2022/web25-SCOPA/25fae958d4148289d6208dd8677581fd892c8225/client/public/favicons/ms-icon-150x150.png
--------------------------------------------------------------------------------
/client/public/favicons/ms-icon-310x310.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2022/web25-SCOPA/25fae958d4148289d6208dd8677581fd892c8225/client/public/favicons/ms-icon-310x310.png
--------------------------------------------------------------------------------
/client/public/favicons/ms-icon-70x70.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2022/web25-SCOPA/25fae958d4148289d6208dd8677581fd892c8225/client/public/favicons/ms-icon-70x70.png
--------------------------------------------------------------------------------
/client/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | SCOPA
28 |
29 |
30 |
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/client/public/loginBG.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2022/web25-SCOPA/25fae958d4148289d6208dd8677581fd892c8225/client/public/loginBG.jpg
--------------------------------------------------------------------------------
/client/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2022/web25-SCOPA/25fae958d4148289d6208dd8677581fd892c8225/client/public/logo.png
--------------------------------------------------------------------------------
/client/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "SCOPA",
3 | "name": "Search Code Partner",
4 | "icons": [
5 | {
6 | "src": "/favicons/android-icon-36x36.png",
7 | "sizes": "36x36",
8 | "type": "image/png",
9 | "density": "0.75"
10 | },
11 | {
12 | "src": "/favicons/android-icon-48x48.png",
13 | "sizes": "48x48",
14 | "type": "image/png",
15 | "density": "1.0"
16 | },
17 | {
18 | "src": "/favicons/android-icon-72x72.png",
19 | "sizes": "72x72",
20 | "type": "image/png",
21 | "density": "1.5"
22 | },
23 | {
24 | "src": "/favicons/android-icon-96x96.png",
25 | "sizes": "96x96",
26 | "type": "image/png",
27 | "density": "2.0"
28 | },
29 | {
30 | "src": "/favicons/android-icon-144x144.png",
31 | "sizes": "144x144",
32 | "type": "image/png",
33 | "density": "3.0"
34 | },
35 | {
36 | "src": "/favicons/android-icon-192x192.png",
37 | "sizes": "192x192",
38 | "type": "image/png",
39 | "density": "4.0"
40 | }
41 | ],
42 | "start_url": ".",
43 | "display": "standalone",
44 | "theme_color": "#000000",
45 | "background_color": "#ffffff"
46 | }
47 |
--------------------------------------------------------------------------------
/client/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { Routes, Route } from 'react-router-dom';
2 |
3 | import { DetailPage, LoginPage, MainPage, RegisterPage, SettingsPage, MessagePage, MessageDetail } from 'pages';
4 | import { LoginLayout, CommonLayout } from 'common';
5 | import { useCheckLogin, useLoadSettings, useSetSSE } from 'hooks';
6 | import { LINK } from 'utils/constants';
7 |
8 | // 라우팅은 이곳에
9 | const App = () => {
10 | useCheckLogin();
11 | useLoadSettings();
12 | useSetSSE();
13 |
14 | return (
15 |
16 | }>
17 | } />
18 | } />
19 | } />
20 | } />
21 | }>
22 | } />
23 |
24 |
25 | }>
26 | } />
27 | } />
28 |
29 |
30 | );
31 | };
32 |
33 | export default App;
34 |
--------------------------------------------------------------------------------
/client/src/assets/svgs/arrowDownIcon.svg:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/client/src/assets/svgs/checkIcon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
43 |
--------------------------------------------------------------------------------
/client/src/assets/svgs/cryIcon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
15 |
--------------------------------------------------------------------------------
/client/src/assets/svgs/editIcon.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/client/src/assets/svgs/emptyHeartIcon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/assets/svgs/errorIcon.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/client/src/assets/svgs/filledHeartIcon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/assets/svgs/filterIcon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/assets/svgs/githubIcon.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/assets/svgs/googleIcon.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/client/src/assets/svgs/index.ts:
--------------------------------------------------------------------------------
1 | export { ReactComponent as ArrowDownIcon } from './arrowDownIcon.svg';
2 | export { ReactComponent as GithubIcon } from './githubIcon.svg';
3 | export { ReactComponent as GoogleIcon } from './googleIcon.svg';
4 | export { ReactComponent as HeartEmptyIcon } from './emptyHeartIcon.svg';
5 | export { ReactComponent as HeartFilledIcon } from './filledHeartIcon.svg';
6 | export { ReactComponent as XIcon } from './xIcon.svg';
7 | export { ReactComponent as SearchIcon } from './searchIcon.svg';
8 | export { ReactComponent as EditIcon } from './editIcon.svg';
9 | export { ReactComponent as SaveIcon } from './saveIcon.svg';
10 | export { ReactComponent as CheckIcon } from './checkIcon.svg';
11 | export { ReactComponent as FilterIcon } from './filterIcon.svg';
12 | export { ReactComponent as ErrorIcon } from './errorIcon.svg';
13 | export { ReactComponent as QuestionIcon } from './questionIcon.svg';
14 | export { ReactComponent as CryIcon } from './cryIcon.svg';
15 | export { ReactComponent as LabelIcon } from './labelIcon.svg';
16 | export { ReactComponent as MessageIcon } from './messageIcon.svg';
17 |
--------------------------------------------------------------------------------
/client/src/assets/svgs/labelIcon.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/client/src/assets/svgs/messageIcon.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/client/src/assets/svgs/questionIcon.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/client/src/assets/svgs/saveIcon.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/client/src/assets/svgs/searchIcon.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/client/src/assets/svgs/xIcon.svg:
--------------------------------------------------------------------------------
1 |
15 |
--------------------------------------------------------------------------------
/client/src/common/Button.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import { ButtonHTMLAttributes } from 'react';
4 | import { css } from '@emotion/react';
5 |
6 | import { COLORS } from 'styles/colors';
7 | import { COMMON_SIZE, FONT_SIZE } from 'styles/sizes';
8 |
9 | interface Props extends ButtonHTMLAttributes {
10 | children: JSX.Element;
11 | className?: string;
12 | ariaLabel?: string;
13 | isSubmit?: boolean;
14 | }
15 |
16 | export const Button = ({ children, onClick, className, ariaLabel, isSubmit }: Props) => {
17 | return (
18 |
27 | );
28 | };
29 |
30 | const buttonStyle = css({
31 | backgroundColor: COLORS.PRIMARY_1,
32 | padding: `5px 20px`,
33 | borderRadius: COMMON_SIZE.BORDER_RADIUS,
34 | transition: `0.1s linear`,
35 |
36 | ':hover': {
37 | backgroundColor: COLORS.PRIMARY_2,
38 | },
39 |
40 | ' span': {
41 | color: COLORS.WHITE,
42 | fontSize: FONT_SIZE.MEDIUM,
43 | },
44 | });
45 |
--------------------------------------------------------------------------------
/client/src/common/CodeBox/index.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import SyntaxHighlighter from 'react-syntax-highlighter';
4 | import { useRecoilValue } from 'recoil';
5 |
6 | import { settingsState } from 'store';
7 | import { THEME_LIST, CODE_SIZE } from 'utils/constants';
8 |
9 | import {
10 | codeBoxWrapperStyle,
11 | languageStyle,
12 | codeBoxStyle,
13 | lineNumberStyle,
14 | noCodeBoxStyle,
15 | cryIconStyle,
16 | cryTextStyle,
17 | } from './styles';
18 |
19 | import { CryIcon } from 'assets/svgs';
20 |
21 | interface Props {
22 | code: string;
23 | language: string;
24 | className?: string;
25 | }
26 |
27 | export const CodeBox = ({ code, language, className }: Props) => {
28 | const settings = useRecoilValue(settingsState);
29 |
30 | return (
31 |
32 | {code && code.length > 0 ? (
33 | <>
34 |
42 | {code}
43 |
44 |
using {language ?? 'none'}
45 | >
46 | ) : (
47 |
48 |
49 | 등록한 코드가 없어요
50 |
51 | )}
52 |
53 | );
54 | };
55 |
--------------------------------------------------------------------------------
/client/src/common/CodeBox/styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/react';
2 |
3 | import { COLORS } from 'styles/colors';
4 | import { getMediaQuery, MEDIA_QUERY } from 'styles/mediaQuery';
5 | import { COMMON_SIZE, FONT_SIZE } from 'styles/sizes';
6 |
7 | export const codeBoxWrapperStyle = (backgroundColor: string) =>
8 | css({
9 | height: '100%',
10 | maxHeight: 480,
11 | backgroundColor,
12 | display: 'flex',
13 | flexDirection: 'column',
14 | border: `1px solid ${COLORS.BOX_BORDER}`,
15 | borderRadius: COMMON_SIZE.BORDER_RADIUS,
16 | overflow: 'hidden',
17 |
18 | [getMediaQuery(MEDIA_QUERY.LG)]: {
19 | maxHeight: 'initial',
20 | },
21 | });
22 |
23 | export const codeBoxStyle = (fontSize: number) => ({
24 | fontSize,
25 | height: '100%',
26 | overflow: 'scroll',
27 | padding: `${COMMON_SIZE.CODE_BOX_PADDING}px 10px`,
28 | flex: 1,
29 | marginBottom: 10,
30 | });
31 |
32 | export const noCodeBoxStyle = css({
33 | height: '100%',
34 | flex: 1,
35 | display: 'flex',
36 | flexDirection: 'column',
37 | alignItems: 'center',
38 | justifyContent: 'center',
39 | });
40 |
41 | export const cryIconStyle = (innerColor: string) =>
42 | css({
43 | width: '30%',
44 | marginBottom: 10,
45 |
46 | ' path': {
47 | stroke: innerColor,
48 | },
49 | });
50 |
51 | export const cryTextStyle = (innerColor: string) =>
52 | css({
53 | fontSize: FONT_SIZE.LARGE,
54 | color: innerColor,
55 | fontWeight: 600,
56 | });
57 |
58 | export const lineNumberStyle = {
59 | width: '3rem',
60 | minWidth: '3rem',
61 | opacity: 0.7,
62 | };
63 |
64 | export const languageStyle = (textColor: string) =>
65 | css({
66 | fontSize: FONT_SIZE.MEDIUM,
67 | padding: 10,
68 | color: textColor,
69 | fontStyle: 'italic',
70 | textAlign: 'end',
71 | });
72 |
--------------------------------------------------------------------------------
/client/src/common/CommonLayout/Header.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/react';
2 |
3 | import { COLORS } from 'styles/colors';
4 | import { getMediaQuery, MEDIA_QUERY } from 'styles/mediaQuery';
5 | import { COMMON_SIZE, FONT_SIZE } from 'styles/sizes';
6 |
7 | export const navigationBarWrapperStyle = css({
8 | width: '100%',
9 | zIndex: 2,
10 | backgroundColor: COLORS.WHITE,
11 | display: 'flex',
12 | flexDirection: 'column',
13 | alignItems: 'center',
14 | padding: `20px 30px 10px`,
15 | borderBottom: `2px solid ${COLORS.PRIMARY_DIM}`,
16 |
17 | '> button': {
18 | height: 30,
19 | padding: 0,
20 | },
21 |
22 | [getMediaQuery(MEDIA_QUERY.MD)]: {
23 | flexDirection: 'row',
24 | alignItems: 'center',
25 | justifyContent: 'space-between',
26 | padding: `20px 50px`,
27 | },
28 | });
29 |
30 | export const headerButtonWrapperStyle = css({
31 | display: 'flex',
32 | flexDirection: 'row',
33 | alignItems: 'center',
34 | justifyContent: 'center',
35 | marginTop: 20,
36 | width: '100%',
37 |
38 | [getMediaQuery(MEDIA_QUERY.MD)]: {
39 | marginTop: 0,
40 | width: 'fit-content',
41 | },
42 | });
43 |
44 | export const headerButtonStyle = (isSelected: boolean) =>
45 | css({
46 | width: 80,
47 | height: 30,
48 | transition: '0.2s linear',
49 | borderRadius: COMMON_SIZE.BORDER_RADIUS,
50 | marginRight: 5,
51 | backgroundColor: isSelected ? COLORS.PRIMARY_DIM : COLORS.WHITE,
52 | position: 'relative',
53 |
54 | '> span': {
55 | color: COLORS.TEXT_1,
56 | fontSize: FONT_SIZE.SMALL,
57 | userSelect: 'none',
58 | },
59 |
60 | ':hover': {
61 | backgroundColor: COLORS.PRIMARY_DIM,
62 | },
63 |
64 | '&:last-of-type': {
65 | marginRight: 0,
66 | },
67 |
68 | [getMediaQuery(MEDIA_QUERY.MD)]: {
69 | width: 100,
70 |
71 | '> span': {
72 | fontSize: FONT_SIZE.MEDIUM,
73 | },
74 | },
75 | });
76 |
77 | export const newMessageAlertStyle = css({
78 | position: 'absolute',
79 | top: 2,
80 | right: 15,
81 | width: 10,
82 | height: 10,
83 | backgroundColor: COLORS.FAILURE,
84 | borderRadius: 5,
85 | });
86 |
--------------------------------------------------------------------------------
/client/src/common/CommonLayout/index.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import { Outlet } from 'react-router-dom';
4 |
5 | import { Header } from './Header';
6 |
7 | import { footerStyle, mainWrapperStyle } from './styles';
8 |
9 | export const CommonLayout = () => {
10 | return (
11 | <>
12 |
13 |
14 |
15 |
16 |
21 | >
22 | );
23 | };
24 |
--------------------------------------------------------------------------------
/client/src/common/CommonLayout/styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/react';
2 |
3 | import { COLORS } from 'styles/colors';
4 |
5 | export const mainWrapperStyle = css({
6 | width: `100%`,
7 | flex: 1,
8 | display: 'flex',
9 | flexDirection: 'column',
10 | backgroundColor: COLORS.LIGHT,
11 | overflowX: 'hidden',
12 | overflowY: 'scroll',
13 | });
14 |
15 | export const footerStyle = css({
16 | width: '100%',
17 | height: 50,
18 | backgroundColor: COLORS.WHITE,
19 | display: 'flex',
20 | flexDirection: 'row',
21 | alignItems: 'center',
22 | justifyContent: 'center',
23 | marginTop: 20,
24 |
25 | '> img': {
26 | width: 20,
27 | height: 20,
28 | margin: '0 10px',
29 | },
30 |
31 | ' span': {
32 | color: COLORS.TEXT_1,
33 | },
34 | });
35 |
--------------------------------------------------------------------------------
/client/src/common/DropdownInput/DropdownInput.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/react';
2 |
3 | import { COLORS } from 'styles/colors';
4 | import { FONT_SIZE, LOGIN_SIZE } from 'styles/sizes';
5 |
6 | export const dropdownWrapperStyle = css({
7 | display: 'flex',
8 | flexDirection: 'row',
9 | alignItems: 'center',
10 | justifyContent: 'center',
11 | position: 'relative',
12 | });
13 |
14 | export const dropdownContainerStyle = css({
15 | width: '100%',
16 | border: `1px solid ${COLORS.PRIMARY_1}`,
17 | display: 'flex',
18 | flexDirection: 'row',
19 | justifyContent: 'space-between',
20 | alignItems: 'center',
21 | padding: 0,
22 | paddingLeft: 10,
23 | borderRadius: LOGIN_SIZE.INPUT_BORDER_RADIUS,
24 | userSelect: 'none',
25 |
26 | ' span': {
27 | fontSize: FONT_SIZE.SMALL,
28 | color: COLORS.TEXT_2,
29 | marginRight: 20,
30 | marginBottom: -5,
31 | overflowY: 'hidden',
32 | overflowX: 'scroll',
33 | whiteSpace: 'nowrap',
34 | },
35 | });
36 |
37 | export const inputButtonArrowStyle = css({
38 | backgroundColor: COLORS.PRIMARY_1,
39 | display: 'flex',
40 | justifyItems: 'center',
41 | alignItems: 'center',
42 | padding: `0 10px`,
43 |
44 | ' svg': {
45 | width: 20,
46 | height: 30,
47 | fill: COLORS.WHITE,
48 | },
49 |
50 | ':hover': {
51 | cursor: 'pointer',
52 | },
53 | });
54 |
--------------------------------------------------------------------------------
/client/src/common/DropdownInput/InterestInput.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import { Dispatch, SetStateAction, useCallback, useState } from 'react';
4 |
5 | import { InterestsBox } from './InterestsBox';
6 | import { useClickOutside } from 'hooks';
7 |
8 | import { dropdownContainerStyle, dropdownWrapperStyle, inputButtonArrowStyle } from './DropdownInput.styles';
9 |
10 | import { ArrowDownIcon } from 'assets/svgs';
11 |
12 | interface Props {
13 | interest: string;
14 | setInterest: Dispatch>;
15 | className?: string;
16 | }
17 |
18 | export const InterestInput = ({ interest, setInterest, className }: Props) => {
19 | const [isShown, setIsShown] = useState(false);
20 | const outSideClickRef = useClickOutside(setIsShown);
21 |
22 | const handleClick = useCallback(() => {
23 | setIsShown((prevState) => !prevState);
24 | }, []);
25 |
26 | return (
27 |
28 |
29 |
{interest.length ? interest : '관심분야'}
30 |
33 |
34 | {isShown &&
}
35 |
36 | );
37 | };
38 |
--------------------------------------------------------------------------------
/client/src/common/DropdownInput/InterestsBox.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/react';
2 |
3 | import { COLORS } from 'styles/colors';
4 | import { COMMON_SIZE, FONT_SIZE } from 'styles/sizes';
5 |
6 | export const interestsBoxStyle = (topPosition: number) =>
7 | css({
8 | zIndex: 10,
9 | width: '100%',
10 | position: 'absolute',
11 | top: topPosition,
12 | right: 0,
13 | display: 'flex',
14 | flexDirection: 'column',
15 | justifyItems: 'center',
16 | alignItems: 'center',
17 | backgroundColor: COLORS.WHITE,
18 | borderRadius: COMMON_SIZE.BORDER_RADIUS,
19 | border: `2px solid ${COLORS.PRIMARY_1}`,
20 | boxShadow: `0 5px 8px 3px ${COLORS.SHADOW}`,
21 | });
22 |
23 | export const interestBoxInnerStyle = css({
24 | width: '100%',
25 | borderBottom: `1px solid ${COLORS.PRIMARY_1}`,
26 |
27 | ':last-child': { border: 'none' },
28 | });
29 |
30 | export const interestButtonStyle = css({
31 | width: '100%',
32 | display: 'flex',
33 | flexDirection: 'column',
34 | justifyItems: 'center',
35 | alignItems: 'center',
36 | padding: `15px 10px`,
37 | transition: `0.1s linear`,
38 |
39 | ' span': {
40 | fontSize: FONT_SIZE.MEDIUM,
41 | color: COLORS.TEXT_1,
42 | userSelect: 'none',
43 | },
44 |
45 | ':hover': {
46 | backgroundColor: COLORS.PRIMARY_DIM,
47 | },
48 | });
49 |
--------------------------------------------------------------------------------
/client/src/common/DropdownInput/InterestsBox.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import { Dispatch, SetStateAction, useCallback } from 'react';
4 |
5 | import { INTEREST_LIST } from 'utils/constants';
6 |
7 | import { interestBoxInnerStyle, interestButtonStyle, interestsBoxStyle } from './InterestsBox.styles';
8 |
9 | interface Props {
10 | setIsShown: Dispatch>;
11 | setInterest: Dispatch>;
12 | topPosition: number;
13 | }
14 |
15 | export const InterestsBox = ({ setIsShown, setInterest, topPosition }: Props) => {
16 | const handleClickInterestButton = useCallback((interest: string) => {
17 | setInterest(interest);
18 | setIsShown((prevState) => !prevState);
19 | }, []);
20 |
21 | return (
22 |
23 | {INTEREST_LIST.map((interest) => (
24 | -
25 |
34 |
35 | ))}
36 |
37 | );
38 | };
39 |
--------------------------------------------------------------------------------
/client/src/common/DropdownInput/SelectedItems.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/react';
2 |
3 | import { COLORS } from 'styles/colors';
4 | import { FONT_SIZE } from 'styles/sizes';
5 |
6 | export const selectedItemsStyle = css({
7 | width: '100%',
8 | color: COLORS.TEXT_1,
9 | display: 'flex',
10 | flexDirection: 'row',
11 | alignItems: 'center',
12 | justifyContent: 'flex-start',
13 | paddingTop: 5,
14 | overflowX: 'scroll',
15 | overflowY: 'hidden',
16 | });
17 |
18 | export const selectedItemStyle = css({
19 | display: 'flex',
20 | alignItems: 'center',
21 | justifyContent: 'flex-start',
22 | marginRight: 10,
23 |
24 | ' span': {
25 | fontSize: FONT_SIZE.SMALL,
26 | color: COLORS.TEXT_2,
27 | marginRight: 5,
28 | },
29 | });
30 |
31 | export const selectedItemButtonStyle = css({
32 | width: 15,
33 | height: 15,
34 | padding: 0,
35 |
36 | ' svg': {
37 | width: 15,
38 | height: 15,
39 | stroke: COLORS.DARK,
40 | },
41 | });
42 |
--------------------------------------------------------------------------------
/client/src/common/DropdownInput/SelectedItems.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import { Dispatch, SetStateAction, useCallback } from 'react';
4 |
5 | import { selectedItemButtonStyle, selectedItemsStyle, selectedItemStyle } from './SelectedItems.styles';
6 |
7 | import { XIcon } from 'assets/svgs';
8 |
9 | interface Props {
10 | itemNames: Array;
11 | setItems: Dispatch>>;
12 | }
13 |
14 | export const SelectedItems = ({ itemNames, setItems }: Props) => {
15 | const handleClick = useCallback(
16 | (itemName: string) => {
17 | setItems(itemNames.filter((name) => name !== itemName));
18 | },
19 | [itemNames]
20 | );
21 |
22 | return (
23 |
24 | {itemNames.map((itemName) => (
25 | -
26 | {itemName}
27 |
36 |
37 | ))}
38 |
39 | );
40 | };
41 |
--------------------------------------------------------------------------------
/client/src/common/DropdownInput/TechStackBox.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/react';
2 |
3 | import { COLORS } from 'styles/colors';
4 | import { COMMON_SIZE, FONT_SIZE } from 'styles/sizes';
5 |
6 | export const techStackBoxWrapperStyle = (topPosition: number) =>
7 | css({
8 | position: 'absolute',
9 | top: topPosition,
10 | zIndex: 10,
11 | left: 0,
12 | width: '100%',
13 | height: 240,
14 | overflowX: 'hidden',
15 | overflowY: 'scroll',
16 | display: 'flex',
17 | flexDirection: 'column',
18 | backgroundColor: COLORS.WHITE,
19 | border: `2px solid ${COLORS.PRIMARY_1}`,
20 | borderRadius: COMMON_SIZE.BORDER_RADIUS,
21 | boxShadow: `0 5px 8px 3px ${COLORS.SHADOW}`,
22 | });
23 |
24 | export const listWrapperStyle = css({
25 | width: '100%',
26 | height: 'fit-content',
27 | display: 'flex',
28 | alignItems: 'center',
29 | borderBottom: `1px solid ${COLORS.PRIMARY_1}`,
30 |
31 | ':last-child': { border: 'none' },
32 | });
33 |
34 | export const listButtonStyle = (isSelected: boolean) =>
35 | css({
36 | display: 'flex',
37 | flexDirection: 'row',
38 | alignItems: 'center',
39 | justifyContent: 'space-between',
40 | width: '100%',
41 | borderColor: COLORS.TEXT_1,
42 | padding: `15px 10px`,
43 | transition: `0.1s linear`,
44 | backgroundColor: isSelected ? COLORS.LIGHT : 'none',
45 |
46 | ':hover': {
47 | cursor: 'pointer',
48 | backgroundColor: isSelected ? COLORS.LIGHT : COLORS.PRIMARY_DIM,
49 | },
50 |
51 | ' svg': {
52 | width: 20,
53 | height: FONT_SIZE.MEDIUM,
54 | fill: COLORS.PRIMARY_2,
55 | },
56 |
57 | ' div': {
58 | width: 20,
59 | height: FONT_SIZE.MEDIUM,
60 | },
61 |
62 | ' span': {
63 | fontSize: FONT_SIZE.MEDIUM,
64 | color: COLORS.TEXT_1,
65 | userSelect: 'none',
66 | },
67 | });
68 |
--------------------------------------------------------------------------------
/client/src/common/DropdownInput/TechStackBox.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import { SetStateAction, Dispatch } from 'react';
4 |
5 | import { STACK_LIST } from 'utils/constants';
6 | import { TechStackCheckbox } from './TechStackCheckbox';
7 |
8 | import { techStackBoxWrapperStyle } from './TechStackBox.styles';
9 |
10 | interface Props {
11 | selectedStacks: Array;
12 | setSelectedStacks: Dispatch>>;
13 | topPosition: number;
14 | }
15 |
16 | export const TechStackBox = ({ selectedStacks, setSelectedStacks, topPosition }: Props) => {
17 | return (
18 |
19 | {STACK_LIST.map((stackName) => (
20 |
26 | ))}
27 |
28 | );
29 | };
30 |
--------------------------------------------------------------------------------
/client/src/common/DropdownInput/TechStackCheckbox.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 | import { Dispatch, SetStateAction, useCallback } from 'react';
3 |
4 | import { listButtonStyle, listWrapperStyle } from './TechStackBox.styles';
5 |
6 | import { CheckIcon } from 'assets/svgs';
7 |
8 | interface Props {
9 | isSelected: boolean;
10 | setSelectedStacks: Dispatch>>;
11 | name: string;
12 | }
13 |
14 | export const TechStackCheckbox = ({ isSelected, setSelectedStacks, name }: Props) => {
15 | const handleClickButton = useCallback(() => {
16 | if (!isSelected) setSelectedStacks((prev) => (prev.length < 3 ? [...prev, name] : prev));
17 | else setSelectedStacks((prev) => prev.filter((value) => value !== name));
18 | }, [isSelected]);
19 |
20 | return (
21 |
22 |
27 |
28 | );
29 | };
30 |
--------------------------------------------------------------------------------
/client/src/common/DropdownInput/TechStackInput.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import { Dispatch, SetStateAction, useCallback, useState } from 'react';
4 |
5 | import { useClickOutside } from 'hooks';
6 | import { SelectedItems } from './SelectedItems';
7 | import { TechStackBox } from './TechStackBox';
8 |
9 | import { dropdownContainerStyle, dropdownWrapperStyle, inputButtonArrowStyle } from './DropdownInput.styles';
10 |
11 | import { ArrowDownIcon } from 'assets/svgs';
12 |
13 | interface Props {
14 | techStack: Array;
15 | setTechStack: Dispatch>>;
16 | className?: string;
17 | }
18 |
19 | export const TechStackInput = ({ techStack, setTechStack, className }: Props) => {
20 | const [isShown, setIsShown] = useState(false);
21 | const outSideClickRef = useClickOutside(setIsShown);
22 |
23 | const handleClick = useCallback(() => {
24 | setIsShown((prevState) => !prevState);
25 | }, []);
26 |
27 | return (
28 |
29 |
30 | {techStack.length > 0 ? (
31 |
32 | ) : (
33 |
기술스택 (최대 3개)
34 | )}
35 |
38 |
39 | {isShown &&
}
40 |
41 | );
42 | };
43 |
--------------------------------------------------------------------------------
/client/src/common/DropdownInput/index.ts:
--------------------------------------------------------------------------------
1 | export { InterestInput } from './InterestInput';
2 | export { TechStackInput } from './TechStackInput';
3 |
--------------------------------------------------------------------------------
/client/src/common/LoadingFallback.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import { css } from '@emotion/react';
4 |
5 | import { COLORS } from 'styles/colors';
6 | import { FONT_SIZE } from 'styles/sizes';
7 |
8 | interface Props {
9 | text: string;
10 | className?: string;
11 | }
12 |
13 | export const LoadingFallback = ({ text, className }: Props) => {
14 | return (
15 |
16 |

17 |
{text}
18 |
19 | );
20 | };
21 |
22 | const loadingFallbackStyle = css({
23 | width: '100%',
24 | height: '100%',
25 | display: 'flex',
26 | flexDirection: 'column',
27 | alignItems: 'center',
28 | justifyContent: 'center',
29 |
30 | ' img': {
31 | width: 100,
32 | height: 100,
33 | },
34 | });
35 |
36 | const loadingTextStyle = css({
37 | marginTop: 20,
38 | fontWeight: 600,
39 | color: COLORS.TEXT_1,
40 | fontSize: FONT_SIZE.LARGE,
41 | });
42 |
--------------------------------------------------------------------------------
/client/src/common/LoginLayout.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import { useEffect } from 'react';
4 | import { Outlet, useNavigate } from 'react-router-dom';
5 | import { useRecoilValue } from 'recoil';
6 | import { css } from '@emotion/react';
7 |
8 | import { currentUserState } from 'store';
9 | import { LINK } from 'utils/constants';
10 |
11 | import { LOGO_SIZE, COMMON_SIZE } from 'styles/sizes';
12 | import { COLORS } from 'styles/colors';
13 | import { getMediaQuery, MEDIA_QUERY } from 'styles/mediaQuery';
14 |
15 | export const LoginLayout = () => {
16 | const currentUser = useRecoilValue(currentUserState);
17 | const nav = useNavigate();
18 |
19 | useEffect(() => {
20 | if (currentUser.id) nav(LINK.MAIN);
21 | }, [currentUser]);
22 |
23 | return (
24 |
25 |
26 |
27 |

34 |
35 |
36 |
37 |
38 | );
39 | };
40 |
41 | const loginPageBackgroundStyle = css({
42 | backgroundImage: `url('./loginBG.jpg')`,
43 | backgroundSize: 'cover',
44 | backgroundRepeat: 'no-repeat',
45 | width: '100vw',
46 | height: '100vh',
47 | });
48 |
49 | const loginPageWrapperStyle = css({
50 | display: 'flex',
51 | justifyContent: 'center',
52 | alignItems: 'center',
53 | width: '100%',
54 | height: '100%',
55 | backdropFilter: `blur(15px) brightness(200%) contrast(50%)`,
56 | });
57 |
58 | const loginPageInnerStyle = css({
59 | display: 'flex',
60 | flexDirection: 'column',
61 | justifyContent: 'center',
62 | alignItems: 'center',
63 | width: '90vw',
64 | height: 'fit-content',
65 | padding: 20,
66 | borderRadius: COMMON_SIZE.BORDER_RADIUS,
67 | backgroundColor: COLORS.WHITE,
68 | boxShadow: `0 0 10px 10px ${COLORS.SHADOW}`,
69 |
70 | [getMediaQuery(MEDIA_QUERY.SM)]: {
71 | width: 540,
72 | padding: 40,
73 | },
74 | });
75 |
76 | const loginPageHeaderImageStyle = css({
77 | marginBottom: 15,
78 | width: 160,
79 | });
80 |
--------------------------------------------------------------------------------
/client/src/common/MiniNavBar.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import { css } from '@emotion/react';
4 |
5 | import { COLORS } from 'styles/colors';
6 | import { getMediaQuery, MEDIA_QUERY } from 'styles/mediaQuery';
7 |
8 | interface Props {
9 | children: JSX.Element;
10 | }
11 |
12 | export const MiniNavBar = ({ children }: Props) => {
13 | return {children}
;
14 | };
15 |
16 | const miniNavBarWrapper = css({
17 | backgroundColor: COLORS.WHITE,
18 | width: '100%',
19 | padding: `20px 50px`,
20 | marginBottom: 20,
21 | display: 'flex',
22 | flexDirection: 'row',
23 | justifyContent: 'space-between',
24 | alignItems: 'center',
25 | boxShadow: `1px 3px 10px 1px ${COLORS.SHADOW}`,
26 |
27 | [getMediaQuery(MEDIA_QUERY.SM)]: {
28 | padding: `30px 80px`,
29 | },
30 | });
31 |
--------------------------------------------------------------------------------
/client/src/common/NavSubtitle.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import { css } from '@emotion/react';
4 |
5 | import { COLORS } from 'styles/colors';
6 | import { FONT_SIZE } from 'styles/sizes';
7 |
8 | interface Props {
9 | text: string;
10 | }
11 |
12 | export const NavSubtitle = ({ text }: Props) => {
13 | return {text}
;
14 | };
15 |
16 | const subtitleStyle = css({
17 | lineHeight: '32px',
18 | color: COLORS.TEXT_1,
19 | fontWeight: 700,
20 | fontSize: FONT_SIZE.LARGE,
21 | });
22 |
--------------------------------------------------------------------------------
/client/src/common/Tooltip.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import { css } from '@emotion/react';
4 |
5 | import { COLORS } from 'styles/colors';
6 | import { COMMON_SIZE } from 'styles/sizes';
7 |
8 | interface Props {
9 | text: string;
10 | }
11 |
12 | export const Tooltip = ({ text }: Props) => {
13 | return (
14 |
15 |
16 | {text}
17 |
18 |
19 | );
20 | };
21 |
22 | const tooltipWrapperStyle = css({
23 | position: 'absolute',
24 | width: 'fit-content',
25 | filter: `drop-shadow(0 0 3px ${COLORS.SHADOW})`,
26 | display: 'flex',
27 | flexDirection: 'column',
28 | alignItems: 'center',
29 | top: 32,
30 | });
31 |
32 | const tooltipInnerStyle = css({
33 | width: 'fit-content',
34 | whiteSpace: 'nowrap',
35 | backgroundColor: COLORS.WHITE,
36 | padding: 10,
37 | borderRadius: COMMON_SIZE.BORDER_RADIUS,
38 | });
39 |
--------------------------------------------------------------------------------
/client/src/common/index.ts:
--------------------------------------------------------------------------------
1 | export { LoginLayout } from './LoginLayout';
2 | export { CommonLayout } from './CommonLayout';
3 | export { MiniNavBar } from './MiniNavBar';
4 | export { InterestInput, TechStackInput } from './DropdownInput';
5 | export { Button } from './Button';
6 | export { CodeBox } from './CodeBox';
7 | export { NavSubtitle } from './NavSubtitle';
8 | export { LoadingFallback } from './LoadingFallback';
9 | export { Tooltip } from './Tooltip';
10 |
--------------------------------------------------------------------------------
/client/src/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export { useClickOutside } from './useClickOutside';
2 | export { useCheckLogin } from './useCheckLogin';
3 | export { useLoadSettings } from './useLoadSettings';
4 | export { useValidateUsername } from './useValidateUsername';
5 | export { useShowTooltip } from './useShowTooltip';
6 | export { useSetSSE } from './useSetSSE';
7 |
--------------------------------------------------------------------------------
/client/src/hooks/useCheckLogin.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import { useLocation, useNavigate } from 'react-router-dom';
3 | import { useRecoilState } from 'recoil';
4 |
5 | import { fetchCheckLogin } from 'services';
6 | import { currentUserState } from 'store';
7 | import { LINK } from 'utils/constants';
8 |
9 | export function useCheckLogin() {
10 | const [currentUser, setCurrentUser] = useRecoilState(currentUserState);
11 | const location = useLocation();
12 | const nav = useNavigate();
13 |
14 | useEffect(() => {
15 | if (currentUser.id) return;
16 | fetchCheckLogin()
17 | .then((res) => {
18 | setCurrentUser({ id: res?.id ?? null });
19 | })
20 | .then(() => {
21 | if (location.pathname === LINK.LOGIN) nav(LINK.MAIN);
22 | })
23 | .catch(() => {
24 | setCurrentUser({ id: null });
25 | });
26 | }, [location]);
27 | }
28 |
--------------------------------------------------------------------------------
/client/src/hooks/useClickOutside.ts:
--------------------------------------------------------------------------------
1 | import { Dispatch, SetStateAction, useCallback, useEffect, useRef } from 'react';
2 |
3 | export function useClickOutside(setIsShown: Dispatch>) {
4 | const ref = useRef(null);
5 | // Modal 또는 Popover 의 최상위 태그는 Div로 맞춰주길 권장
6 |
7 | const handleClickOutside = useCallback((e: MouseEvent) => {
8 | if (!e.target) return; // e.target이 존재하지 않는 경우
9 | if (ref.current && !ref.current.contains(e.target as HTMLElement)) setIsShown(false);
10 | // ref.current가 존재하되 Modal 또는 Popover과 같지 않을 경우 (바깥 요소일 경우) => 이벤트 버블링
11 | }, []);
12 |
13 | useEffect(() => {
14 | document.addEventListener('mousedown', handleClickOutside);
15 | return () => {
16 | document.removeEventListener('mousedown', handleClickOutside);
17 | };
18 | }, []);
19 |
20 | return ref; // 이 ref를 Modal 또는 Popover의 최상위 Div에 부착
21 | }
22 |
--------------------------------------------------------------------------------
/client/src/hooks/useLoadSettings.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import { useRecoilState } from 'recoil';
3 |
4 | import { settingsState } from 'store';
5 | import { getSettingsFromLocalStorage, setSettingsInLocalStorage } from 'utils/storage';
6 |
7 | export function useLoadSettings() {
8 | const [settings, setSettings] = useRecoilState(settingsState);
9 |
10 | useEffect(() => {
11 | const settingsFromLocalStorage = getSettingsFromLocalStorage();
12 | if (!settingsFromLocalStorage) setSettingsInLocalStorage(settings);
13 | else setSettings(settingsFromLocalStorage);
14 | }, []);
15 | }
16 | // TODO: 이 훅 관련 게시글 작성중
17 |
--------------------------------------------------------------------------------
/client/src/hooks/useSetSSE.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { useRecoilValue, useSetRecoilState } from 'recoil';
3 |
4 | import { currentUserState, isNewMessageState, newMessageState } from 'store';
5 | import { API } from 'utils/constants';
6 |
7 | export function useSetSSE() {
8 | const { id: currentUserID } = useRecoilValue(currentUserState);
9 | const setIsNewMessage = useSetRecoilState(isNewMessageState);
10 | const setNewMessage = useSetRecoilState(newMessageState);
11 | const [isSSESet, setIsSSESet] = useState(false);
12 | let eventSource: EventSource;
13 |
14 | useEffect(() => {
15 | if (currentUserID && !isSSESet) {
16 | eventSource = new EventSource(`${process.env.REACT_APP_FETCH_URL}${API.MESSAGE_EVENT}`, {
17 | withCredentials: true,
18 | });
19 |
20 | eventSource.onopen = () => {
21 | setIsSSESet(true);
22 | };
23 |
24 | eventSource.onmessage = ({ data }) => {
25 | const messageUserID = window.location.pathname.split('/message/')[1];
26 | if (!messageUserID) {
27 | setIsNewMessage(true);
28 | return;
29 | }
30 | const message = JSON.parse(data);
31 | if (message.from === messageUserID) setNewMessage(JSON.parse(data));
32 | else setIsNewMessage(true);
33 | };
34 |
35 | return () => {
36 | eventSource.close();
37 | setIsSSESet(false);
38 | };
39 | }
40 | return () => {};
41 | }, [currentUserID]);
42 | }
43 |
--------------------------------------------------------------------------------
/client/src/hooks/useShowTooltip.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useState } from 'react';
2 |
3 | export function useShowTooltip() {
4 | const [isTooltipShown, setIsTooltipShown] = useState(false);
5 |
6 | const handleMouseOverTooltip = useCallback(() => {
7 | setIsTooltipShown(true);
8 | }, []);
9 |
10 | const handleMouseOutTooltip = useCallback(() => {
11 | setIsTooltipShown(false);
12 | }, []);
13 |
14 | return { isTooltipShown, handleMouseOutTooltip, handleMouseOverTooltip };
15 | }
16 |
--------------------------------------------------------------------------------
/client/src/hooks/useValidateUsername.ts:
--------------------------------------------------------------------------------
1 | import { ChangeEvent, Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react';
2 |
3 | import { fetchUsernameServerValidation } from 'services';
4 | import { VALIDATION_RESULT } from 'utils/constants';
5 | import { isValidUsername, isValidUsernameLength, isValidUsernameStr } from 'utils/usernameValidation';
6 |
7 | export function useValidateUsername(setUsername: Dispatch>, defaultUsername?: string) {
8 | const [usernameDraft, setUsernameDraft] = useState(defaultUsername ?? '');
9 | const [validationType, setValidationType] = useState(VALIDATION_RESULT.NULL);
10 |
11 | const handleChangeUsername = useCallback(
12 | (e: ChangeEvent) => setUsernameDraft(e.currentTarget.value),
13 | []
14 | );
15 |
16 | const handleClickValidateButton = () => {
17 | if (!isValidUsername(usernameDraft)) return;
18 | fetchUsernameServerValidation(usernameDraft)
19 | .then((res) => {
20 | if (res.code === 10000) {
21 | setUsername && setUsername(usernameDraft);
22 | setValidationType(VALIDATION_RESULT.SUCCESS);
23 | } else if (res.code === 20001) {
24 | setValidationType(VALIDATION_RESULT.WRONG_STR);
25 | } else if (res.code === 20002) {
26 | setValidationType(VALIDATION_RESULT.DUPLICATED);
27 | }
28 | })
29 | .catch((_) => {
30 | setValidationType(VALIDATION_RESULT.NULL);
31 | alert('이미 사용중인 닉네임이거나, 사용 불가능한 닉네임입니다.');
32 | });
33 | };
34 |
35 | useEffect(() => {
36 | setValidationType(VALIDATION_RESULT.NULL);
37 | if (!isValidUsernameStr(usernameDraft)) setValidationType(VALIDATION_RESULT.WRONG_STR);
38 | else if (!isValidUsernameLength(usernameDraft)) setValidationType(VALIDATION_RESULT.WRONG_LENGTH);
39 | else setValidationType(VALIDATION_RESULT.NULL);
40 | }, [usernameDraft]);
41 |
42 | return { handleClickValidateButton, validationType, usernameDraft, handleChangeUsername };
43 | }
44 |
--------------------------------------------------------------------------------
/client/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import { BrowserRouter } from 'react-router-dom';
4 | import { RecoilRoot } from 'recoil';
5 |
6 | import App from './App';
7 |
8 | import './reset.css';
9 |
10 | const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
11 | root.render(
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | );
20 |
--------------------------------------------------------------------------------
/client/src/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/pages/DetailPage/DetailInner.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback } from 'react';
2 | import { useNavigate, useSearchParams } from 'react-router-dom';
3 |
4 | import { ViewModeContainer } from './ViewModeContainer';
5 | import { EditModeContainer } from './EditModeContainer';
6 | import { LINK } from 'utils/constants';
7 | import { ProfileType } from 'types/profile';
8 | import { useRecoilValue } from 'recoil';
9 | import { currentUserState } from 'store';
10 |
11 | interface Props {
12 | userId: string | null;
13 | promise: {
14 | read: () => ProfileType;
15 | };
16 | }
17 |
18 | export const DetailInner = ({ userId, promise }: Props) => {
19 | const [params] = useSearchParams();
20 | const { id: currentUserID } = useRecoilValue(currentUserState);
21 | const nav = useNavigate();
22 | const mode = params.get('mode');
23 | const profileData = promise.read();
24 |
25 | const handleClickEditButton = useCallback(() => {
26 | nav(`${LINK.MYPAGE}${mode === 'edit' ? '' : '?mode=edit'}`);
27 | }, [mode]);
28 |
29 | if (!userId) return null;
30 | return mode === 'edit' ? (
31 |
36 | ) : (
37 |
38 | );
39 | };
40 |
--------------------------------------------------------------------------------
/client/src/pages/DetailPage/EditModeContainer/BottomProfileEditor.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/react';
2 |
3 | import { COLORS } from 'styles/colors';
4 | import { getMediaQuery, MEDIA_QUERY } from 'styles/mediaQuery';
5 | import { COMMON_SIZE, FONT_SIZE } from 'styles/sizes';
6 |
7 | export const profileBoxWrapperStyle = css({
8 | backgroundColor: COLORS.WHITE,
9 | border: `${COMMON_SIZE.LINE_WIDTH}px solid ${COLORS.BOX_BORDER}`,
10 | borderRadius: COMMON_SIZE.BORDER_RADIUS,
11 | padding: `${COMMON_SIZE.PROFILE_BOX_PADDING_VERTICAL}px ${COMMON_SIZE.PROFILE_BOX_PADDING_HORIZONTAL}px`,
12 | gridRow: '5 / 7',
13 | gridColumn: '1',
14 |
15 | [getMediaQuery(MEDIA_QUERY.LG)]: {
16 | gridRow: '2 / 4',
17 | gridColumn: '2',
18 | },
19 | });
20 |
21 | export const subtitleStyle = css({
22 | fontSize: FONT_SIZE.LARGE,
23 | fontStyle: 'italic',
24 | fontWeight: 600,
25 | marginBottom: COMMON_SIZE.PROFILE_BOX_DD_MARGIN_BOTTOM,
26 | color: COLORS.TEXT_1,
27 |
28 | '::before': {
29 | content: `'#'`,
30 | marginRight: 10,
31 | },
32 | });
33 |
34 | export const fieldsetStyle = css({
35 | display: 'flex',
36 | flexDirection: 'column',
37 |
38 | '> label': {
39 | fontSize: FONT_SIZE.MEDIUM,
40 | fontWeight: 600,
41 | color: COLORS.TEXT_1,
42 | marginBottom: COMMON_SIZE.PROFILE_BOX_DT_MARGIN_BOTTOM,
43 | },
44 |
45 | ' input': {
46 | color: COLORS.TEXT_1,
47 | backgroundColor: COLORS.WHITE,
48 | height: COMMON_SIZE.EDITOR_BOX_INPUT_HEIGHT - 3,
49 | border: 'none',
50 | borderBottom: `1px solid ${COLORS.PRIMARY_2}`,
51 | padding: '1px 5px',
52 | marginBottom: COMMON_SIZE.PROFILE_BOX_DD_MARGIN_BOTTOM,
53 | },
54 | });
55 |
56 | export const requirementFieldWrapperStyle = css({
57 | '> input:nth-of-type(1)': {
58 | marginRight: 10,
59 | },
60 | });
61 |
--------------------------------------------------------------------------------
/client/src/pages/DetailPage/EditModeContainer/BottomProfileEditor.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import { RefObject } from 'react';
4 |
5 | import { ProfileType } from 'types/profile';
6 |
7 | import {
8 | fieldsetStyle,
9 | profileBoxWrapperStyle,
10 | requirementFieldWrapperStyle,
11 | subtitleStyle,
12 | } from './BottomProfileEditor.styles';
13 |
14 | interface Props {
15 | workTypeRef: RefObject;
16 | workTimeRef: RefObject;
17 | requirementRef1: RefObject;
18 | requirementRef2: RefObject;
19 | profileData: ProfileType;
20 | }
21 |
22 | export const BottomProfileEditor = ({
23 | workTimeRef,
24 | workTypeRef,
25 | requirementRef1,
26 | requirementRef2,
27 | profileData,
28 | }: Props) => {
29 | return (
30 |
72 | );
73 | };
74 |
--------------------------------------------------------------------------------
/client/src/pages/DetailPage/EditModeContainer/CodeEditor.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/react';
2 |
3 | import { COLORS } from 'styles/colors';
4 | import { COMMON_SIZE } from 'styles/sizes';
5 |
6 | export const codeEditorWrapperStyle = (codeBoxThemeIndex: number) =>
7 | css({
8 | gridRow: '1 / 4',
9 | gridColumn: '1',
10 | border: `1px solid ${COLORS.BOX_BORDER}`,
11 | overflow: 'hidden',
12 | borderRadius: COMMON_SIZE.BORDER_RADIUS,
13 | display: 'flex',
14 | flexDirection: 'column',
15 | alignItems: 'flex-end',
16 | backgroundColor: codeBoxThemeIndex < 3 ? '#FFFFFE' : '#1E1E1E',
17 | });
18 |
19 | export const codeEditorStyle = css({
20 | flex: 1,
21 | padding: '20px 10px',
22 | marginBottom: 10,
23 | });
24 |
25 | export const languageSelectorWrapperStyle = css({
26 | padding: 10,
27 | height: 36,
28 | });
29 |
30 | export const languageSelectorStyle = (codeBoxThemeIndex: number) =>
31 | css({
32 | height: 16,
33 | background: 'none',
34 | border: 'none',
35 | color: codeBoxThemeIndex < 3 ? COLORS.DARK : COLORS.LIGHT,
36 | });
37 |
--------------------------------------------------------------------------------
/client/src/pages/DetailPage/EditModeContainer/CodeEditor.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import { ChangeEvent, Dispatch, SetStateAction, useCallback } from 'react';
4 | import { useRecoilValue } from 'recoil';
5 | import Editor from '@monaco-editor/react';
6 |
7 | import { settingsState } from 'store';
8 | import { LANGUAGE_LIST } from 'utils/constants';
9 |
10 | import {
11 | codeEditorStyle,
12 | codeEditorWrapperStyle,
13 | languageSelectorStyle,
14 | languageSelectorWrapperStyle,
15 | } from './CodeEditor.styles';
16 |
17 | interface Props {
18 | code: string;
19 | setCode: Dispatch>;
20 | language: string;
21 | setLanguage: Dispatch>;
22 | }
23 |
24 | export const CodeEditor = ({ code, setCode, language, setLanguage }: Props) => {
25 | const { codeBoxTheme: codeBoxThemeIndex } = useRecoilValue(settingsState);
26 | const defaultLanguage = language; // 맨 처음 받아오는 언어 save
27 |
28 | const handleChangeLanguage = useCallback((e: ChangeEvent) => {
29 | setLanguage(e.target.value);
30 | }, []);
31 |
32 | const handleChangeCode = useCallback((value: string | undefined) => {
33 | setCode(value ?? '');
34 | }, []);
35 |
36 | return (
37 |
38 |
46 |
47 |
58 |
59 |
60 | );
61 | };
62 |
--------------------------------------------------------------------------------
/client/src/pages/DetailPage/EditModeContainer/NavBarInner.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/react';
2 |
3 | import { VALIDATION_RESULT } from 'utils/constants';
4 |
5 | import { COLORS } from 'styles/colors';
6 | import { FONT_SIZE } from 'styles/sizes';
7 |
8 | export const inputWrapperStyle = css({
9 | position: 'relative',
10 | });
11 |
12 | export const usernameEditorInputStyle = css({
13 | border: 'none',
14 | width: 150,
15 | borderBottom: `1px solid ${COLORS.PRIMARY_2}`,
16 | fontSize: FONT_SIZE.LARGE,
17 | color: COLORS.TEXT_1,
18 | paddingRight: 30,
19 | });
20 |
21 | export const errorIconWrapperStyle = (validationType: number) =>
22 | css({
23 | display: validationType === VALIDATION_RESULT.NULL ? 'none' : 'block',
24 | position: 'absolute',
25 | zIndex: 2,
26 | top: 3,
27 | left: 157.5,
28 |
29 | '> svg': {
30 | width: 20,
31 | height: 20,
32 | fill: validationType === VALIDATION_RESULT.SUCCESS ? COLORS.SUCCESS : COLORS.FAILURE,
33 | },
34 | });
35 |
36 | export const validateButtonStyle = css({
37 | backgroundColor: COLORS.WHITE,
38 | border: `1px solid ${COLORS.PRIMARY_2}`,
39 | height: 32,
40 | marginLeft: 10,
41 |
42 | ' span': {
43 | color: COLORS.PRIMARY_2,
44 | },
45 |
46 | ':hover': {
47 | backgroundColor: COLORS.PRIMARY_DIM,
48 | },
49 | });
50 |
51 | export const editButtonStyle = css({
52 | display: 'flex',
53 | flexDirection: 'row',
54 | alignItems: 'center',
55 | height: 32,
56 |
57 | ' svg': {
58 | width: 22,
59 | height: 22,
60 | marginRight: 10,
61 | fill: COLORS.WHITE,
62 | },
63 | });
64 |
65 | export const cancelButtonStyle = css({
66 | backgroundColor: COLORS.WHITE,
67 | marginRight: 20,
68 | border: `1px solid ${COLORS.PRIMARY_2}`,
69 | height: 32,
70 |
71 | '> svg': {
72 | width: 20,
73 | height: 20,
74 | stroke: COLORS.PRIMARY_2,
75 | },
76 |
77 | ':hover': {
78 | backgroundColor: COLORS.PRIMARY_DIM,
79 | },
80 | });
81 |
82 | export const buttonWrapperStyle = css({
83 | display: 'flex',
84 | flexDirection: 'row',
85 | });
86 |
--------------------------------------------------------------------------------
/client/src/pages/DetailPage/EditModeContainer/NavBarInner.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import { Dispatch, SetStateAction } from 'react';
4 |
5 | import { Button, Tooltip } from 'common';
6 | import { useShowTooltip, useValidateUsername } from 'hooks';
7 | import { VALIDATION_INFO, VALIDATION_RESULT } from 'utils/constants';
8 |
9 | import {
10 | buttonWrapperStyle,
11 | cancelButtonStyle,
12 | editButtonStyle,
13 | errorIconWrapperStyle,
14 | inputWrapperStyle,
15 | usernameEditorInputStyle,
16 | validateButtonStyle,
17 | } from './NavBarInner.styles';
18 |
19 | import { CheckIcon, ErrorIcon, SaveIcon, XIcon } from 'assets/svgs';
20 |
21 | interface Props {
22 | onClickCancelButton: () => void;
23 | onClickSaveProfile: () => Promise;
24 | username: string;
25 | setUsername: Dispatch>;
26 | }
27 |
28 | export const NavBarInner = ({ onClickCancelButton, onClickSaveProfile, username, setUsername }: Props) => {
29 | const { handleClickValidateButton, validationType, usernameDraft, handleChangeUsername } = useValidateUsername(
30 | setUsername,
31 | username
32 | );
33 | const { isTooltipShown, handleMouseOutTooltip, handleMouseOverTooltip } = useShowTooltip();
34 |
35 | return (
36 | <>
37 |
38 |
39 |
46 | {validationType === VALIDATION_RESULT.SUCCESS ? : }
47 |
48 |
51 | {isTooltipShown &&
}
52 |
53 |
54 |
57 |
63 |
64 | >
65 | );
66 | };
67 |
--------------------------------------------------------------------------------
/client/src/pages/DetailPage/EditModeContainer/TopProfileEditor.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/react';
2 |
3 | import { COLORS } from 'styles/colors';
4 | import { getMediaQuery, MEDIA_QUERY } from 'styles/mediaQuery';
5 | import { COMMON_SIZE, FONT_SIZE } from 'styles/sizes';
6 |
7 | export const profileBoxWrapperStyle = css({
8 | backgroundColor: COLORS.WHITE,
9 | border: `${COMMON_SIZE.LINE_WIDTH}px solid ${COLORS.BOX_BORDER}`,
10 | borderRadius: COMMON_SIZE.BORDER_RADIUS,
11 | padding: `${COMMON_SIZE.PROFILE_BOX_PADDING_VERTICAL}px ${COMMON_SIZE.PROFILE_BOX_PADDING_HORIZONTAL}px`,
12 | gridRow: '4 / 5',
13 | gridColumn: '1',
14 |
15 | [getMediaQuery(MEDIA_QUERY.LG)]: {
16 | gridRow: '1',
17 | gridColumn: '2',
18 | },
19 | });
20 |
21 | export const subtitleStyle = css({
22 | fontSize: FONT_SIZE.LARGE,
23 | fontStyle: 'italic',
24 | fontWeight: 600,
25 | marginBottom: COMMON_SIZE.PROFILE_BOX_DD_MARGIN_BOTTOM,
26 | color: COLORS.TEXT_1,
27 |
28 | '::before': {
29 | content: `'#'`,
30 | marginRight: 10,
31 | },
32 | });
33 |
34 | export const interestsStyle = css({
35 | marginBottom: 10,
36 | });
37 |
--------------------------------------------------------------------------------
/client/src/pages/DetailPage/EditModeContainer/TopProfileEditor.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import { Dispatch, SetStateAction } from 'react';
4 |
5 | import { InterestInput, TechStackInput } from 'common';
6 |
7 | import { interestsStyle, profileBoxWrapperStyle, subtitleStyle } from './TopProfileEditor.styles';
8 |
9 | interface Props {
10 | interest: string;
11 | setInterest: Dispatch>;
12 | techStack: string[];
13 | setTechStack: Dispatch>;
14 | }
15 |
16 | export const TopProfileEditor = ({ interest, setInterest, techStack, setTechStack }: Props) => {
17 | return (
18 |
19 | 저는 이런 분야에 관심이 있어요
20 |
21 |
22 |
23 | );
24 | };
25 |
--------------------------------------------------------------------------------
/client/src/pages/DetailPage/EditModeContainer/index.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import { MiniNavBar } from 'common';
4 | import { ProfileType } from 'types/profile';
5 | import { BottomProfileEditor } from './BottomProfileEditor';
6 | import { CodeEditor } from './CodeEditor';
7 | import { TopProfileEditor } from './TopProfileEditor';
8 | import { useSetProfileEditor } from './useSetProfileEditor';
9 | import { NavBarInner } from './NavBarInner';
10 |
11 | import { detailProfileWrapperStyle } from './styles';
12 |
13 | interface Props {
14 | isEditable: boolean;
15 | profileData: ProfileType;
16 | onClickCancelButton: () => void;
17 | }
18 |
19 | export const EditModeContainer = ({ isEditable, profileData, onClickCancelButton }: Props) => {
20 | if (!isEditable) throw new Error();
21 | const {
22 | workTypeRef,
23 | workTimeRef,
24 | requirementRef1,
25 | requirementRef2,
26 | username,
27 | setUsername,
28 | code,
29 | setCode,
30 | interest,
31 | setInterest,
32 | techStack,
33 | setTechStack,
34 | language,
35 | setLanguage,
36 | handleClickSaveProfile,
37 | } = useSetProfileEditor(profileData);
38 |
39 | return (
40 | <>
41 |
42 |
48 |
49 |
50 |
51 |
57 |
64 |
65 | >
66 | );
67 | };
68 |
--------------------------------------------------------------------------------
/client/src/pages/DetailPage/EditModeContainer/styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/react';
2 |
3 | import { getMediaQuery, MEDIA_QUERY } from 'styles/mediaQuery';
4 |
5 | export const detailProfileWrapperStyle = css({
6 | display: 'grid',
7 | gridTemplateRows: 'repeat(6, minmax(0, 1fr))',
8 | gap: 30,
9 | paddingLeft: 30,
10 | paddingRight: 30,
11 |
12 | [getMediaQuery(MEDIA_QUERY.LG)]: {
13 | height: '69vh',
14 | gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
15 | gridTemplateRows: 'repeat(3, minmax(0, 1fr))',
16 | paddingLeft: 60,
17 | paddingRight: 60,
18 | },
19 | });
20 |
--------------------------------------------------------------------------------
/client/src/pages/DetailPage/EditModeContainer/useSetProfileEditor.ts:
--------------------------------------------------------------------------------
1 | import { useRef, useState } from 'react';
2 | import { useNavigate } from 'react-router-dom';
3 |
4 | import { ProfileType } from 'types/profile';
5 | import { LINK } from 'utils/constants';
6 | import { fetchEditUserProfile } from '../services';
7 |
8 | export function useSetProfileEditor(profileData: ProfileType) {
9 | const [interest, setInterest] = useState(profileData.interest);
10 | const [techStack, setTechStack] = useState(profileData.techStack);
11 | const [language, setLanguage] = useState(profileData.language);
12 | const [code, setCode] = useState(profileData.code);
13 | const [username, setUsername] = useState(profileData.username);
14 | const workTypeRef = useRef(null);
15 | const workTimeRef = useRef(null);
16 | const requirementRef1 = useRef(null);
17 | const requirementRef2 = useRef(null);
18 | const nav = useNavigate();
19 |
20 | const handleClickSaveProfile = async () => {
21 | const newData: ProfileType = {
22 | username,
23 | code: code ?? '',
24 | language: language ?? 'javascript',
25 | interest,
26 | techStack,
27 | requirements: [requirementRef1.current?.value ?? '', requirementRef2.current?.value ?? ''],
28 | worktype: workTypeRef.current?.value ?? '',
29 | worktime: workTimeRef.current?.value ?? '',
30 | email: profileData.email,
31 | };
32 | await fetchEditUserProfile(newData)
33 | .then(() => {
34 | nav(LINK.MYPAGE);
35 | })
36 | .catch((err) => {
37 | alert(err);
38 | });
39 | };
40 | // 의존성을 갖는 변수가 너무 많아 (각각의 상태값이 변할 때마다 함수가 변함) useCallback 사용 X
41 |
42 | return {
43 | workTypeRef,
44 | workTimeRef,
45 | requirementRef1,
46 | requirementRef2,
47 | code,
48 | setCode,
49 | interest,
50 | setInterest,
51 | techStack,
52 | setTechStack,
53 | language,
54 | setLanguage,
55 | username,
56 | setUsername,
57 | handleClickSaveProfile,
58 | };
59 | }
60 |
--------------------------------------------------------------------------------
/client/src/pages/DetailPage/ViewModeContainer/BottomProfileBox.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/react';
2 |
3 | import { COLORS } from 'styles/colors';
4 | import { getMediaQuery, MEDIA_QUERY } from 'styles/mediaQuery';
5 | import { COMMON_SIZE, FONT_SIZE } from 'styles/sizes';
6 |
7 | export const profileBoxWrapperStyle = css({
8 | backgroundColor: COLORS.WHITE,
9 | border: `${COMMON_SIZE.LINE_WIDTH}px solid ${COLORS.BOX_BORDER}`,
10 | borderRadius: COMMON_SIZE.BORDER_RADIUS,
11 | padding: `${COMMON_SIZE.PROFILE_BOX_PADDING_VERTICAL}px ${COMMON_SIZE.PROFILE_BOX_PADDING_HORIZONTAL}px`,
12 | gridRow: '5 / 7',
13 | gridColumn: '1',
14 | display: 'flex',
15 | flexDirection: 'column',
16 | height: '100%',
17 |
18 | [getMediaQuery(MEDIA_QUERY.LG)]: {
19 | gridRow: '2 / 4',
20 | gridColumn: '2',
21 | },
22 | });
23 |
24 | export const descriptionListStyle = css({
25 | flex: 1,
26 | display: 'grid',
27 | gridTemplateRows: 'minmax(0, 1fr) minmax(0, 2fr) minmax(0, 1fr) minmax(0, 2fr) minmax(0, 1fr) minmax(0, 2fr)',
28 | gap: 5,
29 | overflow: 'hidden',
30 |
31 | '> h4': {
32 | display: 'flex',
33 | alignItems: 'center',
34 | fontSize: FONT_SIZE.MEDIUM,
35 | fontWeight: 600,
36 | color: COLORS.TEXT_1,
37 | },
38 |
39 | '> span': {
40 | display: 'inline-block',
41 | overflowX: 'hidden',
42 | overflowY: 'scroll',
43 | color: COLORS.TEXT_1,
44 | lineHeight: `${COMMON_SIZE.EDITOR_BOX_INPUT_HEIGHT}px`,
45 | },
46 | });
47 |
48 | export const descriptionTagWrapperStyle = css({
49 | width: '100%',
50 | display: 'flex',
51 | flexDirection: 'row',
52 | justifyContent: 'space-between',
53 | overflow: 'hidden',
54 |
55 | ' span': {
56 | display: 'inline-block',
57 | padding: 5,
58 | paddingBottom: 0,
59 | width: '45%',
60 | height: 'fit-content',
61 | fontSize: FONT_SIZE.MEDIUM,
62 | borderRadius: COMMON_SIZE.BORDER_RADIUS,
63 | border: `2px solid ${COLORS.PRIMARY_1}`,
64 | overflowX: 'scroll',
65 | overflowY: 'hidden',
66 | },
67 | });
68 |
--------------------------------------------------------------------------------
/client/src/pages/DetailPage/ViewModeContainer/BottomProfileBox.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import { descriptionListStyle, descriptionTagWrapperStyle, profileBoxWrapperStyle } from './BottomProfileBox.styles';
4 | import { subtitleStyle } from './TopProfileBox.styles';
5 |
6 | interface Props {
7 | workType: string;
8 | workTime: string;
9 | requirements: string[];
10 | }
11 |
12 | export const BottomProfileBox = ({ workType, workTime, requirements }: Props) => {
13 | return (
14 |
15 | 저는 이런 요구사항이 있어요
16 |
17 |
필수 요구사항
18 |
19 | {requirements.map((requirement, index) => (
20 | // eslint-disable-next-line react/no-array-index-key
21 | # {requirement}
22 | ))}
23 |
24 |
작업 형태
25 |
{workType}
26 |
작업 선호 시간대
27 |
{workTime}
28 |
29 |
30 | );
31 | };
32 |
--------------------------------------------------------------------------------
/client/src/pages/DetailPage/ViewModeContainer/InterestBadge.styles.ts:
--------------------------------------------------------------------------------
1 | import { css, keyframes } from '@emotion/react';
2 |
3 | import { INTEREST_COLOR_BASE, INTEREST_COLOR_BORDER } from 'utils/constants';
4 |
5 | import { COLORS } from 'styles/colors';
6 | import { FONT_SIZE } from 'styles/sizes';
7 |
8 | const animation = keyframes`
9 | from {
10 | transform: skewX(30deg) translateX(-400%);
11 | } to {
12 | transform: skewX(30deg) translateX(600%);
13 | }`;
14 |
15 | export const interestStyle = (interest: string) =>
16 | css({
17 | display: 'flex',
18 | position: 'relative',
19 | alignItems: 'center',
20 | justifyContent: 'center',
21 | padding: `0 10px`,
22 | width: '100%',
23 | height: '100%',
24 | borderRadius: 5,
25 | backgroundColor: INTEREST_COLOR_BASE[interest],
26 | overflow: 'hidden',
27 | border: `1px solid ${INTEREST_COLOR_BORDER[interest]}`,
28 |
29 | '::before': {
30 | position: 'absolute',
31 | top: 0,
32 | left: 0,
33 | width: '20%',
34 | height: '100%',
35 | backgroundColor: COLORS.WHITE,
36 | opacity: 0.5,
37 | content: '""',
38 | transform: 'skewX(30deg) translateX(30px)',
39 | animation: `${animation} 2s ease infinite`,
40 | },
41 |
42 | ' span': {
43 | width: '100%',
44 | textAlign: 'center',
45 | overflow: 'hidden',
46 | textOverflow: 'ellipsis',
47 | whiteSpace: 'nowrap',
48 | fontSize: FONT_SIZE.LARGE,
49 | color: COLORS.WHITE,
50 | fontWeight: 600,
51 | userSelect: 'none',
52 | },
53 | });
54 |
--------------------------------------------------------------------------------
/client/src/pages/DetailPage/ViewModeContainer/InterestBadge.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import { INTEREST_KOR } from 'utils/constants';
4 |
5 | import { interestStyle } from './InterestBadge.styles';
6 |
7 | interface Props {
8 | interest: string;
9 | }
10 |
11 | export const InterestBadge = ({ interest }: Props) => {
12 | return (
13 |
14 | {INTEREST_KOR[interest]}
15 |
16 | );
17 | };
18 |
--------------------------------------------------------------------------------
/client/src/pages/DetailPage/ViewModeContainer/TechStackBadge.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/react';
2 |
3 | import { COLORS } from 'styles/colors';
4 | import { FONT_SIZE } from 'styles/sizes';
5 |
6 | export const techBadgeStyle = css({
7 | display: 'flex',
8 | position: 'relative',
9 | alignItems: 'center',
10 | justifyContent: 'center',
11 | width: '100%',
12 | height: '100%',
13 | borderRadius: 5,
14 | backgroundColor: COLORS.PRIMARY_1,
15 | padding: `0 10px`,
16 |
17 | ' span': {
18 | width: '100%',
19 | textAlign: 'center',
20 | color: COLORS.WHITE,
21 | lineHeight: `${FONT_SIZE.MEDIUM + 10}px`,
22 | verticalAlign: 'center',
23 | fontSize: FONT_SIZE.MEDIUM,
24 | fontWeight: 600,
25 | overflow: 'hidden',
26 | userSelect: 'none',
27 | textOverflow: 'ellipsis',
28 | whiteSpace: 'nowrap',
29 | },
30 | });
31 |
--------------------------------------------------------------------------------
/client/src/pages/DetailPage/ViewModeContainer/TechStackBadge.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import { techBadgeStyle } from './TechStackBadge.styles';
4 |
5 | interface Props {
6 | techStack: string;
7 | }
8 |
9 | export const TechStackBadge = ({ techStack }: Props) => {
10 | return (
11 |
12 | {techStack}
13 |
14 | );
15 | };
16 |
--------------------------------------------------------------------------------
/client/src/pages/DetailPage/ViewModeContainer/TopProfileBox.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/react';
2 |
3 | import { COLORS } from 'styles/colors';
4 | import { getMediaQuery, MEDIA_QUERY } from 'styles/mediaQuery';
5 | import { COMMON_SIZE, FONT_SIZE } from 'styles/sizes';
6 |
7 | export const profileBoxWrapperStyle = css({
8 | display: 'flex',
9 | flexDirection: 'column',
10 | backgroundColor: COLORS.WHITE,
11 | border: `${COMMON_SIZE.LINE_WIDTH}px solid ${COLORS.BOX_BORDER}`,
12 | borderRadius: COMMON_SIZE.BORDER_RADIUS,
13 | padding: `${COMMON_SIZE.PROFILE_BOX_PADDING_VERTICAL}px ${COMMON_SIZE.PROFILE_BOX_PADDING_HORIZONTAL}px`,
14 | gridRow: '4 / 5',
15 | gridColumn: '1',
16 |
17 | [getMediaQuery(MEDIA_QUERY.LG)]: {
18 | gridRow: '1',
19 | gridColumn: '2',
20 | },
21 | });
22 |
23 | export const subtitleStyle = css({
24 | fontSize: FONT_SIZE.LARGE,
25 | height: FONT_SIZE.LARGE,
26 | fontStyle: 'italic',
27 | fontWeight: 600,
28 | marginBottom: COMMON_SIZE.PROFILE_BOX_DD_MARGIN_BOTTOM,
29 | color: COLORS.TEXT_1,
30 |
31 | '::before': {
32 | content: `'#'`,
33 | marginRight: 10,
34 | },
35 | });
36 |
37 | export const profileBoxInnerStyle = css({
38 | width: '100%',
39 | height: '100%',
40 | display: 'grid',
41 | gridTemplateColumns: 'minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr)',
42 | gap: 10,
43 | });
44 |
--------------------------------------------------------------------------------
/client/src/pages/DetailPage/ViewModeContainer/TopProfileBox.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import { InterestBadge } from './InterestBadge';
4 | import { TechStackBadge } from './TechStackBadge';
5 |
6 | import { subtitleStyle, profileBoxWrapperStyle, profileBoxInnerStyle } from './TopProfileBox.styles';
7 |
8 | interface Props {
9 | interest: string;
10 | techStacks: string[];
11 | }
12 |
13 | export const TopProfileBox = ({ interest, techStacks }: Props) => {
14 | return (
15 |
16 | 저는 이런 분야에 관심이 있어요
17 |
18 |
19 | {techStacks.map((techStack) => (
20 |
21 | ))}
22 |
23 |
24 | );
25 | };
26 |
--------------------------------------------------------------------------------
/client/src/pages/DetailPage/ViewModeContainer/styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/react';
2 |
3 | import { COLORS } from 'styles/colors';
4 | import { getMediaQuery, MEDIA_QUERY } from 'styles/mediaQuery';
5 | import { COMMON_SIZE } from 'styles/sizes';
6 |
7 | export const detailProfileWrapperStyle = css({
8 | display: 'grid',
9 | gridTemplateRows: 'repeat(6, minmax(0, 1fr))',
10 | gap: 30,
11 | paddingLeft: 30,
12 | paddingRight: 30,
13 |
14 | [getMediaQuery(MEDIA_QUERY.LG)]: {
15 | height: '69vh',
16 | gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
17 | gridTemplateRows: 'repeat(3, minmax(0, 1fr))',
18 | paddingLeft: 60,
19 | paddingRight: 60,
20 | },
21 | });
22 |
23 | export const detailDummyNavBarStyle = css({
24 | height: 32,
25 | width: 10,
26 | });
27 |
28 | export const detailLoadingFallbackStyle = css({
29 | height: '69vh',
30 | });
31 |
32 | export const editButtonStyle = css({
33 | display: 'flex',
34 | flexDirection: 'row',
35 | alignItems: 'center',
36 | height: 32,
37 |
38 | ' svg': {
39 | width: 22,
40 | height: 22,
41 | marginRight: 10,
42 | fill: COLORS.WHITE,
43 | },
44 | });
45 |
46 | export const likeButtonWrapperStyle = css({
47 | height: 30,
48 | });
49 |
50 | export const likeButtonStyle = css({
51 | width: 30,
52 | height: 30,
53 | padding: 0,
54 |
55 | ' svg': {
56 | width: 30,
57 | height: 30,
58 | fill: COLORS.PRIMARY_2,
59 | },
60 |
61 | ':first-of-type': {
62 | marginRight: 20,
63 | },
64 | });
65 |
66 | export const codeSectionStyle = css({
67 | gridRow: '1 / 4',
68 | gridColumn: '1',
69 | borderRadius: COMMON_SIZE.BORDER_RADIUS,
70 | overflow: 'hidden',
71 | });
72 |
--------------------------------------------------------------------------------
/client/src/pages/DetailPage/index.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import { Suspense, useEffect } from 'react';
4 | import { useLocation, useNavigate, useParams } from 'react-router-dom';
5 | import { useRecoilValue } from 'recoil';
6 | import { ErrorBoundary } from 'react-error-boundary';
7 |
8 | import { LoadingFallback, MiniNavBar } from 'common';
9 | import { currentUserState } from 'store';
10 | import { fetchUserData } from './services';
11 | import { LINK } from 'utils/constants';
12 | import { DetailInner } from './DetailInner';
13 |
14 | import { detailDummyNavBarStyle, detailLoadingFallbackStyle } from './ViewModeContainer/styles';
15 |
16 | const ErrorFallback = (isMine: boolean) => {
17 | const nav = useNavigate();
18 |
19 | useEffect(() => {
20 | alert(isMine ? '로그인 정보가 없습니다.' : '존재하지 않는 페이지이거나, 권한이 없습니다.');
21 | nav(LINK.MAIN);
22 | }, []);
23 | return null;
24 | };
25 |
26 | const DetailLoadingFallback = () => {
27 | return (
28 | <>
29 |
30 |
31 |
32 |
33 | >
34 | );
35 | };
36 |
37 | export const DetailPage = () => {
38 | const { pathname } = useLocation();
39 | const { id = null } = useParams();
40 | const { id: currentUserID } = useRecoilValue(currentUserState);
41 |
42 | const isMine = pathname === LINK.MYPAGE;
43 | const promise = fetchUserData(isMine ? currentUserID : id);
44 |
45 | return (
46 | ErrorFallback(isMine)}>
47 | }>
48 |
49 |
50 |
51 | );
52 | };
53 |
--------------------------------------------------------------------------------
/client/src/pages/DetailPage/services/fetchEditUserProfile.ts:
--------------------------------------------------------------------------------
1 | import { ProfileType } from 'types/profile';
2 | import { API } from 'utils/constants';
3 | import { checkCustomCode, checkStatusCode } from 'utils/fetchUtils';
4 |
5 | export function fetchEditUserProfile(data: ProfileType) {
6 | return fetch(`${process.env.REACT_APP_FETCH_URL}${API.EDIT}`, {
7 | credentials: 'include',
8 | method: 'put',
9 | headers: { 'Content-Type': 'application/json' },
10 | body: JSON.stringify(data),
11 | })
12 | .then(checkStatusCode)
13 | .then(checkCustomCode);
14 | }
15 |
--------------------------------------------------------------------------------
/client/src/pages/DetailPage/services/fetchUserData.ts:
--------------------------------------------------------------------------------
1 | import { ProfileType } from 'types/profile';
2 | import { API, FETCH_STATUS } from 'utils/constants';
3 | import { checkCustomCode, checkStatusCode } from 'utils/fetchUtils';
4 |
5 | export function fetchUserData(userId: string | null) {
6 | let status = FETCH_STATUS.PENDING;
7 | let result: Error | ProfileType;
8 |
9 | const suspender = fetch(`${process.env.REACT_APP_FETCH_URL}${API.DETAIL}${userId}`)
10 | .then(checkStatusCode)
11 | .then(checkCustomCode)
12 | .then(
13 | (res) => {
14 | status = FETCH_STATUS.SUCCESS;
15 | result = res;
16 | },
17 | (err) => {
18 | status = FETCH_STATUS.ERROR;
19 | result = err;
20 | }
21 | );
22 |
23 | return {
24 | read: () => {
25 | if (status === FETCH_STATUS.PENDING) throw suspender;
26 | if (status === FETCH_STATUS.ERROR) throw result;
27 | return result as unknown as ProfileType; // Error 타입의 변수의 경우 위에서 반드시 throw됨
28 | },
29 | };
30 | }
31 |
--------------------------------------------------------------------------------
/client/src/pages/DetailPage/services/index.ts:
--------------------------------------------------------------------------------
1 | export { fetchUserData } from './fetchUserData';
2 | export { fetchEditUserProfile } from './fetchEditUserProfile';
3 |
--------------------------------------------------------------------------------
/client/src/pages/LoginPage/LoginButtonComponent.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/react';
2 |
3 | import { COLORS } from 'styles/colors';
4 | import { getMediaQuery, MEDIA_QUERY } from 'styles/mediaQuery';
5 | import { COMMON_SIZE, FONT_SIZE } from 'styles/sizes';
6 |
7 | export const loginButtonLinkStyle = css({
8 | marginTop: 20,
9 |
10 | [getMediaQuery(MEDIA_QUERY.SM)]: {
11 | marginTop: 30,
12 | },
13 | });
14 |
15 | export const loginButtonWrapperStyle = (isBackgroundBlack: boolean) =>
16 | css({
17 | display: 'flex',
18 | flexDirection: 'row',
19 | alignItems: 'center',
20 | justifyContent: 'space-between',
21 | backgroundColor: isBackgroundBlack ? COLORS.BLACK : COLORS.WHITE,
22 | border: `3px solid ${COLORS.BLACK}`,
23 | borderRadius: COMMON_SIZE.BORDER_RADIUS,
24 | padding: `10px 20px`,
25 |
26 | '> svg': {
27 | width: 30,
28 | height: 30,
29 | },
30 |
31 | [getMediaQuery(MEDIA_QUERY.SM)]: {
32 | padding: `15px 50px`,
33 | },
34 | });
35 |
36 | export const loginButtonTextStyle = (isBackgroundBlack: boolean) =>
37 | css({
38 | flex: 1,
39 | color: isBackgroundBlack ? COLORS.WHITE : COLORS.BLACK,
40 | fontSize: FONT_SIZE.MEDIUM,
41 | fontStyle: 'normal',
42 | margin: `0 10px`,
43 | whiteSpace: 'nowrap',
44 | overflow: 'hidden',
45 | textOverflow: 'ellipsis',
46 |
47 | [getMediaQuery(MEDIA_QUERY.SM)]: {
48 | fontSize: FONT_SIZE.LARGE,
49 | margin: `0 20px`,
50 | },
51 | });
52 |
53 | export const loginButtonDummyDivStyle = css({
54 | width: 30,
55 | height: 30,
56 | });
57 |
--------------------------------------------------------------------------------
/client/src/pages/LoginPage/LoginButtonComponent.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import { ReactElement } from 'react';
4 |
5 | import {
6 | loginButtonDummyDivStyle,
7 | loginButtonLinkStyle,
8 | loginButtonTextStyle,
9 | loginButtonWrapperStyle,
10 | } from './LoginButtonComponent.styles';
11 |
12 | interface Props {
13 | link: string;
14 | icon: ReactElement;
15 | innerText: string;
16 | isBackgroundBlack?: boolean;
17 | }
18 |
19 | export const LoginButtonComponent = ({ link, icon, innerText, isBackgroundBlack = false }: Props) => {
20 | return (
21 |
22 |
23 | {icon}
24 |
{innerText} 로그인
25 |
26 |
27 |
28 | );
29 | };
30 |
--------------------------------------------------------------------------------
/client/src/pages/LoginPage/index.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import { LoginButtonComponent } from './LoginButtonComponent';
4 | import { css } from '@emotion/react';
5 |
6 | import { COLORS } from 'styles/colors';
7 | import { FONT_SIZE } from 'styles/sizes';
8 |
9 | import { GithubIcon, GoogleIcon } from 'assets/svgs';
10 |
11 | export const LoginPage = () => {
12 | return (
13 | <>
14 | 나와 찰떡궁합인 팀원 찾기
15 | }
18 | innerText='Github'
19 | isBackgroundBlack
20 | />
21 | }
24 | innerText='Google'
25 | />
26 | >
27 | );
28 | };
29 |
30 | const loginPageSubHeaderStyle = css({
31 | fontSize: FONT_SIZE.MEDIUM,
32 | color: COLORS.TEXT_1,
33 | textAlign: 'center',
34 | marginBottom: 10,
35 | });
36 |
--------------------------------------------------------------------------------
/client/src/pages/MainPage/InterestTag.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/react';
2 |
3 | import { INTEREST_COLOR_BASE, INTEREST_COLOR_BORDER } from 'utils/constants';
4 |
5 | import { FONT_SIZE } from 'styles/sizes';
6 | import { COLORS } from 'styles/colors';
7 |
8 | export const interestTagStyle = () =>
9 | css({
10 | position: 'absolute',
11 | top: -15,
12 | left: 0,
13 | zIndex: 2,
14 | });
15 |
16 | export const interestTagInnerStyle = (interest: string) =>
17 | css({
18 | position: 'relative',
19 | width: '100%',
20 | height: '100%',
21 | display: 'flex',
22 | alignItems: 'center',
23 | justifyContent: 'center',
24 |
25 | ' span': {
26 | position: 'absolute',
27 | zIndex: 3,
28 | textAlign: 'center',
29 | fontSize: FONT_SIZE.MEDIUM,
30 | color: COLORS.WHITE,
31 | userSelect: 'none',
32 | fontWeight: 600,
33 | },
34 |
35 | ' svg': {
36 | width: 70,
37 | height: 70,
38 | filter: `drop-shadow(0 0 1px ${INTEREST_COLOR_BORDER[interest]})`,
39 | fill: INTEREST_COLOR_BASE[interest],
40 | },
41 | });
42 |
--------------------------------------------------------------------------------
/client/src/pages/MainPage/InterestTag.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import { LabelIcon } from 'assets/svgs';
4 | import { interestTagInnerStyle, interestTagStyle } from './InterestTag.styles';
5 |
6 | interface Props {
7 | interest: string;
8 | }
9 |
10 | export const InterestTag = ({ interest }: Props) => {
11 | const shorthandText = {
12 | Frontend: 'FE',
13 | Backend: 'BE',
14 | iOS: 'iOS',
15 | Android: 'And',
16 | }[interest];
17 | return (
18 |
19 |
20 | {shorthandText}
21 |
22 |
23 |
24 | );
25 | };
26 |
--------------------------------------------------------------------------------
/client/src/pages/MainPage/NavBar.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/react';
2 |
3 | import { COLORS } from 'styles/colors';
4 | import { getMediaQuery, MEDIA_QUERY } from 'styles/mediaQuery';
5 | import { FONT_SIZE } from 'styles/sizes';
6 |
7 | export const filterIconStyle = css({
8 | width: 30,
9 | height: 30,
10 | fill: COLORS.PRIMARY_2,
11 | display: 'none',
12 |
13 | [getMediaQuery(MEDIA_QUERY.MD)]: {
14 | display: 'block',
15 | },
16 | });
17 |
18 | export const likedCheckStyle = css({
19 | display: 'flex',
20 | alignItems: 'center',
21 | marginBottom: 10,
22 |
23 | ' input': {
24 | margin: 0,
25 | },
26 |
27 | ' label': {
28 | marginLeft: 5,
29 | color: COLORS.TEXT_1,
30 | fontSize: FONT_SIZE.SMALL,
31 | overflowWrap: 'break-word',
32 | },
33 |
34 | [getMediaQuery(MEDIA_QUERY.LG)]: {
35 | marginBottom: 0,
36 | marginRight: 10,
37 | },
38 | });
39 |
40 | export const inputWrapperStyle = css({
41 | margin: `0 15px`,
42 | flex: 1,
43 | display: 'flex',
44 | flexDirection: 'row',
45 | });
46 |
47 | export const dropdownWrapperStyle = css({
48 | display: 'flex',
49 | flexDirection: 'column',
50 | alignItems: 'flex-start',
51 | flex: 1,
52 |
53 | [getMediaQuery(MEDIA_QUERY.LG)]: {
54 | flexDirection: 'row',
55 | alignItems: 'center',
56 | marginRight: 20,
57 | },
58 | });
59 |
60 | export const searchButtonWrapperStyle = css({
61 | display: 'flex',
62 | flexDirection: 'column',
63 | alignItems: 'center',
64 | justifyContent: 'center',
65 |
66 | [getMediaQuery(MEDIA_QUERY.LG)]: {
67 | flexDirection: 'row',
68 | },
69 | });
70 |
71 | export const interestBoxStyle = css({
72 | width: '30vw',
73 | marginBottom: 10,
74 |
75 | [getMediaQuery(MEDIA_QUERY.LG)]: {
76 | width: '20vw',
77 | marginBottom: 0,
78 | marginRight: 20,
79 | },
80 | });
81 |
82 | export const techStackBoxStyle = css({
83 | width: '40vw',
84 | marginRight: 20,
85 |
86 | [getMediaQuery(MEDIA_QUERY.LG)]: {
87 | width: '30vw',
88 | },
89 | });
90 |
91 | export const searchButtonStyle = css({
92 | height: 30,
93 |
94 | ' svg': {
95 | height: 20,
96 | },
97 | });
98 |
--------------------------------------------------------------------------------
/client/src/pages/MainPage/Profile.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/react';
2 |
3 | import { COLORS } from 'styles/colors';
4 | import { COMMON_SIZE } from 'styles/sizes';
5 | import { getMediaQuery } from 'styles/mediaQuery';
6 |
7 | export const profileBoxStyle = css({
8 | position: 'relative',
9 | height: 540,
10 | display: 'grid',
11 | gridTemplateRows: '10fr 1fr',
12 | flexGrow: 1,
13 | padding: 20,
14 | borderRadius: COMMON_SIZE.BORDER_RADIUS,
15 | backgroundColor: COLORS.WHITE,
16 | border: `${COMMON_SIZE.LINE_WIDTH}px solid ${COLORS.BOX_BORDER}`,
17 | gap: 20,
18 | textAlign: 'start',
19 |
20 | [getMediaQuery(COMMON_SIZE.PROFILELIST_TRIPLE_WIDTH)]: {
21 | maxWidth: 'calc((100% / 3) - 15px)',
22 | minWidth: 'calc((100% / 3) - 15px)',
23 | },
24 | [getMediaQuery(COMMON_SIZE.PROFILELIST_SINGLE_WIDTH, COMMON_SIZE.PROFILELIST_TRIPLE_WIDTH)]: {
25 | maxWidth: 'calc((100% / 2) - 5px)',
26 | },
27 | [getMediaQuery(0, COMMON_SIZE.PROFILELIST_SINGLE_WIDTH)]: {
28 | minWidth: 220,
29 | },
30 | });
31 |
32 | export const profileBoxBottomStyle = css({
33 | color: COLORS.TEXT_1,
34 | display: 'flex',
35 | height: 40,
36 | width: '100%',
37 | overflowX: 'hidden',
38 | overflowY: 'hidden',
39 | alignItems: 'center',
40 | justifyContent: 'space-between',
41 | gap: 10,
42 | });
43 |
44 | export const textWrapperStyle = css({
45 | display: 'flex',
46 | flexDirection: 'column',
47 | flex: 1,
48 | overflow: 'hidden',
49 | });
50 |
51 | export const rowTextWrapperStyle = css({
52 | width: '100%',
53 | display: 'flex',
54 | flexDirection: 'row',
55 | alignItems: 'center',
56 | justifyContent: 'flex-start',
57 | overflow: 'hidden',
58 |
59 | ' span': {
60 | display: 'inline-block',
61 | color: COLORS.TEXT_1,
62 | flex: 1,
63 | overflow: 'hidden',
64 | textOverflow: 'ellipsis',
65 | whiteSpace: 'nowrap',
66 | fontWeight: '600',
67 | marginRight: 10,
68 | },
69 | });
70 |
71 | export const bottomTextStyle = css({
72 | color: COLORS.TEXT_2,
73 | width: '100%',
74 | overflowX: 'scroll',
75 | overflowY: 'hidden',
76 | opacity: 0.6,
77 | fontWeight: '600',
78 | marginTop: 5,
79 | });
80 |
81 | export const favoriteButtonStyle = css({
82 | width: 40,
83 | height: 40,
84 | display: 'flex',
85 | justifyItems: 'center',
86 | alignItems: 'center',
87 |
88 | ' svg': {
89 | width: 30,
90 | height: 30,
91 | fill: COLORS.PRIMARY_2,
92 | },
93 | });
94 |
--------------------------------------------------------------------------------
/client/src/pages/MainPage/ProfileList.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import { SingleProfileType } from 'types/profile';
4 | import Profile from './Profile';
5 |
6 | import { profileListStyle } from './styles';
7 |
8 | interface Props {
9 | profileData: Array;
10 | }
11 |
12 | export const ProfileList = ({ profileData }: Props) => {
13 | return (
14 |
15 | {profileData.map((data) => (
16 |
17 | ))}
18 |
19 | );
20 | };
21 |
--------------------------------------------------------------------------------
/client/src/pages/MainPage/fetchFilteredData.ts:
--------------------------------------------------------------------------------
1 | import { API } from 'utils/constants';
2 | import { checkCustomCode, checkStatusCode } from 'utils/fetchUtils';
3 |
4 | export function fetchFilteredData(paramObject: URLSearchParams) {
5 | return fetch(`${process.env.REACT_APP_FETCH_URL}${API.PROFILE}?${paramObject}`, {
6 | credentials: 'include',
7 | })
8 | .then(checkStatusCode)
9 | .then(checkCustomCode)
10 | .catch((err) => {
11 | alert(err);
12 | });
13 | }
14 |
--------------------------------------------------------------------------------
/client/src/pages/MainPage/styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/react';
2 |
3 | import { COLORS } from 'styles/colors';
4 | import { getMediaQuery, MEDIA_QUERY } from 'styles/mediaQuery';
5 | import { FONT_SIZE } from 'styles/sizes';
6 |
7 | export const profileListStyle = css({
8 | display: 'flex',
9 | flexWrap: 'wrap',
10 | alignItems: 'flex-start',
11 | justifyItems: 'center',
12 | gap: 10,
13 | flex: 1,
14 | padding: `10px 20px`,
15 | height: `calc(100% - 50px)`,
16 |
17 | [getMediaQuery(MEDIA_QUERY.SM)]: {
18 | padding: `15px 60px`,
19 | },
20 | });
21 |
22 | export const paginationStyle = css({
23 | fontSize: FONT_SIZE.MEDIUM,
24 | width: '100%',
25 | display: 'flex',
26 | alignItems: 'center',
27 | justifyContent: 'center',
28 | color: COLORS.TEXT_1,
29 | marginBottom: 10,
30 |
31 | ' .pagination': {
32 | width: 'fit-content',
33 | display: 'flex',
34 | justifyContent: 'center',
35 | marginTop: 10,
36 | border: `1px solid ${COLORS.BOX_BORDER}`,
37 | borderRadius: 5,
38 | overflow: 'hidden',
39 | },
40 |
41 | li: {
42 | width: 30,
43 | height: 30,
44 | borderLeft: `1px solid ${COLORS.BOX_BORDER}`,
45 | backgroundColor: COLORS.WHITE,
46 | display: 'flex',
47 | justifyContent: 'center',
48 | alignItems: 'center',
49 | transition: '0.1s linear',
50 |
51 | ':first-of-type': {
52 | border: 'none',
53 | },
54 |
55 | ':hover': {
56 | backgroundColor: COLORS.LIGHT,
57 | cursor: 'pointer',
58 | },
59 |
60 | ' a': {
61 | textDecoration: 'none',
62 | color: COLORS.PRIMARY_1,
63 | },
64 |
65 | '&.active': {
66 | backgroundColor: COLORS.PRIMARY_1,
67 | },
68 |
69 | '&.active a': {
70 | color: COLORS.WHITE,
71 | },
72 | },
73 | });
74 |
--------------------------------------------------------------------------------
/client/src/pages/MessagePage/MessageDetail/MessageDetailInner.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import { useCallback } from 'react';
4 | import { useRecoilValue } from 'recoil';
5 | import { useNavigate } from 'react-router-dom';
6 |
7 | import { Button } from 'common';
8 | import { currentUserState } from 'store';
9 | import { MessageDetailType, SingleMessageType } from 'types/message';
10 | import { useManageMessageData, useScrollBottom } from '../hooks';
11 | import { MessageElement } from './MessageElement';
12 | import { MessageTopBar } from '../MessageTopBar';
13 |
14 | import { messageDetailInputWrapperStyle, messageDetailListStyle, messageInputStyle, goBackButtonStyle } from './styles';
15 |
16 | import { ArrowDownIcon } from 'assets/svgs';
17 |
18 | interface Props {
19 | promise: { read: () => MessageDetailType };
20 | userId: string;
21 | }
22 |
23 | export const MessageDetailInner = ({ promise, userId }: Props) => {
24 | const { id: currentUserID } = useRecoilValue(currentUserState);
25 | const { toUsername, contents } = promise.read();
26 | const { handleChangeInputValue, handleSubmitMessage, inputValue, currentMessageData } = useManageMessageData(
27 | currentUserID ?? '',
28 | contents,
29 | userId
30 | );
31 | const { messageDetailListRef } = useScrollBottom(currentMessageData);
32 | const nav = useNavigate();
33 |
34 | const handleClickBackButton = useCallback(() => {
35 | nav('/message');
36 | }, []);
37 |
38 | return (
39 | <>
40 |
41 | <>
42 |
45 | {toUsername}
46 | >
47 |
48 |
49 | {currentMessageData.map((data: SingleMessageType, idx: number) => (
50 | // eslint-disable-next-line react/no-array-index-key
51 |
52 | ))}
53 |
54 |
67 | >
68 | );
69 | };
70 |
--------------------------------------------------------------------------------
/client/src/pages/MessagePage/MessageDetail/MessageElement.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/react';
2 |
3 | import { COLORS } from 'styles/colors';
4 | import { COMMON_SIZE, FONT_SIZE } from 'styles/sizes';
5 |
6 | export const messageElementWrapperStyle = (isMine: boolean) =>
7 | css({
8 | padding: `0 10px`,
9 | width: '100%',
10 | display: 'flex',
11 | flexDirection: isMine ? 'row-reverse' : 'row',
12 | alignItems: 'flex-end',
13 | marginBottom: 10,
14 |
15 | '&:last-of-type': {
16 | marginBottom: 0,
17 | },
18 | });
19 |
20 | export const messageBubbleStyle = css({
21 | width: '70%',
22 | padding: `8px 15px`,
23 | backgroundColor: COLORS.WHITE,
24 | borderRadius: COMMON_SIZE.BORDER_RADIUS,
25 |
26 | ' span': {
27 | whiteSpace: 'pre-wrap',
28 | overflowWrap: 'anywhere',
29 | },
30 | });
31 |
32 | export const messageTimeStyle = css({
33 | marginLeft: 10,
34 | fontSize: FONT_SIZE.SMALL,
35 | color: COLORS.DARK,
36 | });
37 |
--------------------------------------------------------------------------------
/client/src/pages/MessagePage/MessageDetail/MessageElement.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import { SingleMessageType } from 'types/message';
4 |
5 | import { messageBubbleStyle, messageElementWrapperStyle, messageTimeStyle } from './MessageElement.styles';
6 |
7 | interface Props {
8 | messageData: SingleMessageType;
9 | isMine: boolean;
10 | }
11 |
12 | export const MessageElement = ({ messageData, isMine }: Props) => {
13 | const time = new Date(messageData.createdAt).toLocaleTimeString();
14 | return (
15 |
16 |
17 | {messageData.content}
18 |
19 | {time}
20 |
21 | );
22 | };
23 |
--------------------------------------------------------------------------------
/client/src/pages/MessagePage/MessageDetail/index.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import { Suspense } from 'react';
4 | import { useParams } from 'react-router-dom';
5 |
6 | import { LoadingFallback } from 'common';
7 | import { MessageDetailInner } from './MessageDetailInner';
8 | import { fetchMessageDetail } from '../services';
9 |
10 | import { messagePageSectionStyle } from '../styles';
11 |
12 | export const MessageDetail = () => {
13 | const { id = null } = useParams();
14 | const promise = fetchMessageDetail(id);
15 |
16 | if (!id) return null;
17 | return (
18 |
23 | );
24 | };
25 |
--------------------------------------------------------------------------------
/client/src/pages/MessagePage/MessageDetail/styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/react';
2 |
3 | import { COLORS } from 'styles/colors';
4 | import { COMMON_SIZE } from 'styles/sizes';
5 |
6 | export const messageDetailListStyle = css({
7 | width: '100%',
8 | flex: 1,
9 | padding: '10px 0',
10 | backgroundColor: COLORS.LIGHT,
11 | overflowX: 'hidden',
12 | overflowY: 'scroll',
13 | });
14 |
15 | export const messageDetailInputWrapperStyle = css({
16 | width: '100%',
17 | height: 60,
18 | padding: '0 10px',
19 | display: 'flex',
20 | flexDirection: 'row',
21 | alignItems: 'center',
22 | });
23 |
24 | export const messageInputStyle = css({
25 | flex: 1,
26 | marginRight: 20,
27 | padding: '10px 10px',
28 | border: `1px solid ${COLORS.BOX_BORDER}`,
29 | borderRadius: COMMON_SIZE.BORDER_RADIUS,
30 | });
31 |
32 | export const goBackButtonStyle = css({
33 | padding: 0,
34 | width: 20,
35 | height: 20,
36 | marginRight: 10,
37 |
38 | ' svg': {
39 | width: 20,
40 | height: 20,
41 | transform: 'rotateZ(90deg)',
42 | fill: COLORS.PRIMARY_2,
43 | },
44 | });
45 |
--------------------------------------------------------------------------------
/client/src/pages/MessagePage/MessageList.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/react';
2 |
3 | import { COLORS } from 'styles/colors';
4 | import { FONT_SIZE } from 'styles/sizes';
5 |
6 | export const messageListWrapperStyle = css({
7 | flex: 1,
8 | overflowX: 'hidden',
9 | overflowY: 'scroll',
10 | });
11 |
12 | export const messageListElementStyle = (isSelected: boolean) =>
13 | css({
14 | backgroundColor: isSelected ? COLORS.PRIMARY_DIM : COLORS.WHITE,
15 | width: '100%',
16 | height: 'fit-content',
17 | borderBottom: `1px solid ${COLORS.BOX_BORDER}`,
18 |
19 | ':last-of-type': {
20 | borderBottom: 'none',
21 | },
22 | });
23 |
24 | export const messageListButtonStyle = css({
25 | display: 'flex',
26 | alignItems: 'flex-start',
27 | width: '100%',
28 | padding: `5px 10px`,
29 |
30 | ' span': {
31 | color: COLORS.TEXT_1,
32 | fontSize: FONT_SIZE.MEDIUM,
33 | },
34 |
35 | ':hover': {
36 | backgroundColor: COLORS.PRIMARY_DIM,
37 | },
38 | });
39 |
--------------------------------------------------------------------------------
/client/src/pages/MessagePage/MessageList.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import { useCallback } from 'react';
4 | import { useNavigate, useParams } from 'react-router-dom';
5 |
6 | import { MessageMetaDataType } from 'types/message';
7 |
8 | import { messageListButtonStyle, messageListElementStyle, messageListWrapperStyle } from './MessageList.styles';
9 |
10 | interface Props {
11 | promise: { read: () => MessageMetaDataType[] };
12 | }
13 |
14 | export const MessageList = ({ promise }: Props) => {
15 | const nav = useNavigate();
16 | const { id = null } = useParams();
17 | const messageData = promise.read();
18 |
19 | const handleClickUser = useCallback(
20 | (clickedID: string) => {
21 | if (clickedID === id) nav('/message');
22 | else nav(`/message/${clickedID}`);
23 | },
24 | [id]
25 | );
26 |
27 | return (
28 |
29 | {messageData.map((data: MessageMetaDataType) => (
30 | -
31 |
34 |
35 | ))}
36 |
37 | );
38 | };
39 |
--------------------------------------------------------------------------------
/client/src/pages/MessagePage/MessageTopBar.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import { css } from '@emotion/react';
4 |
5 | import { COLORS } from 'styles/colors';
6 | import { COMMON_SIZE, FONT_SIZE } from 'styles/sizes';
7 |
8 | interface Props {
9 | children: JSX.Element;
10 | }
11 |
12 | export const MessageTopBar = ({ children }: Props) => {
13 | return {children}
;
14 | };
15 |
16 | export const messageTopBarStyle = css({
17 | padding: `10px 10px ${COMMON_SIZE.PROFILE_BOX_PADDING_VERTICAL}px`,
18 | fontSize: FONT_SIZE.LARGE,
19 | borderBottom: `2px solid ${COLORS.BOX_BORDER}`,
20 | display: 'flex',
21 | flexDirection: 'row',
22 | alignItems: 'center',
23 | justifyContent: 'flex-start',
24 | color: COLORS.TEXT_1,
25 | backgroundColor: COLORS.WHITE,
26 | zIndex: 2,
27 | });
28 |
--------------------------------------------------------------------------------
/client/src/pages/MessagePage/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export { useManageMessageData } from './useManageMessageData';
2 | export { useScrollBottom } from './useScrollBottom';
3 |
--------------------------------------------------------------------------------
/client/src/pages/MessagePage/hooks/useManageMessageData.ts:
--------------------------------------------------------------------------------
1 | import { ChangeEvent, FormEvent, useEffect, useState } from 'react';
2 | import { useRecoilState } from 'recoil';
3 |
4 | import { newMessageState } from 'store';
5 | import { SingleMessageType } from 'types/message';
6 | import { fetchSendMessage } from '../services';
7 |
8 | export function useManageMessageData(currentUserID: string, contents: SingleMessageType[], userId: string) {
9 | const [inputValue, setInputValue] = useState('');
10 | const [currentMessageData, setCurrentMessageData] = useState([]);
11 | const [newMessage, setNewMessage] = useRecoilState(newMessageState);
12 |
13 | async function handleSubmitMessage(e: FormEvent) {
14 | e.preventDefault();
15 | if (inputValue.length < 1) return;
16 | await fetchSendMessage(userId, inputValue)
17 | .then(() => {
18 | setCurrentMessageData((prevState) => [
19 | ...prevState,
20 | { from: currentUserID ?? '', content: inputValue, createdAt: new Date().toString() },
21 | ]);
22 | })
23 | .then(() => {
24 | setInputValue('');
25 | });
26 | }
27 |
28 | function handleChangeInputValue(e: ChangeEvent) {
29 | setInputValue(e.currentTarget.value);
30 | }
31 |
32 | useEffect(() => {
33 | setCurrentMessageData(contents);
34 | }, [contents]);
35 |
36 | useEffect(() => {
37 | if (!newMessage) return;
38 | setCurrentMessageData((prevState) => [...prevState, newMessage]);
39 | }, [newMessage]);
40 |
41 | return {
42 | handleChangeInputValue,
43 | handleSubmitMessage,
44 | inputValue,
45 | currentMessageData,
46 | };
47 | }
48 |
--------------------------------------------------------------------------------
/client/src/pages/MessagePage/hooks/useScrollBottom.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react';
2 | import { SingleMessageType } from 'types/message';
3 |
4 | export function useScrollBottom(currentMessageData: SingleMessageType[]) {
5 | const messageDetailListRef = useRef(null);
6 |
7 | function scrollToEnd() {
8 | if (messageDetailListRef.current) {
9 | messageDetailListRef.current.scrollTop = messageDetailListRef.current.scrollHeight;
10 | }
11 | }
12 |
13 | useEffect(() => {
14 | scrollToEnd();
15 | }, [currentMessageData]);
16 |
17 | return { messageDetailListRef };
18 | }
19 |
--------------------------------------------------------------------------------
/client/src/pages/MessagePage/index.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import { Suspense, useEffect } from 'react';
4 | import { useResetRecoilState } from 'recoil';
5 | import { Outlet, useNavigate } from 'react-router-dom';
6 | import { ErrorBoundary } from 'react-error-boundary';
7 |
8 | import { LoadingFallback, MiniNavBar, NavSubtitle } from 'common';
9 | import { isNewMessageState } from 'store';
10 | import { MessageList } from './MessageList';
11 | import { MessageDetail } from './MessageDetail';
12 | import { fetchMessageList } from './services';
13 | import { LINK } from 'utils/constants';
14 | import { MessageTopBar } from './MessageTopBar';
15 |
16 | import { messagePageSectionStyle, messagePageWrapperStyle } from './styles';
17 |
18 | const ErrorFallback = () => {
19 | const nav = useNavigate();
20 |
21 | useEffect(() => {
22 | alert('쪽지 목록을 불러오는 데에 실패하였습니다.');
23 | nav(LINK.MAIN);
24 | }, []);
25 | return null;
26 | };
27 |
28 | export const MessagePage = () => {
29 | const resetIsNewMessage = useResetRecoilState(isNewMessageState);
30 | const promise = fetchMessageList();
31 |
32 | useEffect(() => {
33 | resetIsNewMessage();
34 | }, []);
35 | return (
36 | <>
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | 대화 목록
45 |
46 | }>
47 |
48 |
49 |
50 |
51 |
52 |
53 | >
54 | );
55 | };
56 |
57 | export { MessageDetail };
58 |
--------------------------------------------------------------------------------
/client/src/pages/MessagePage/services/fetchMessageDetail.ts:
--------------------------------------------------------------------------------
1 | import { MessageDetailType } from 'types/message';
2 | import { API, FETCH_STATUS } from 'utils/constants';
3 | import { checkCustomCode, checkStatusCode } from 'utils/fetchUtils';
4 |
5 | export function fetchMessageDetail(userId: string | null) {
6 | if (!userId) throw new Error('존재하지 않는 아이디입니다');
7 | let status = FETCH_STATUS.PENDING;
8 | let result: Error | MessageDetailType;
9 |
10 | const suspender = fetch(`${process.env.REACT_APP_FETCH_URL}${API.MESSAGE_DETAIL}${userId}`, {
11 | credentials: 'include',
12 | method: 'get',
13 | headers: { 'Content-Type': 'application/json' },
14 | })
15 | .then(checkStatusCode)
16 | .then(checkCustomCode)
17 | .then(
18 | (res) => {
19 | status = FETCH_STATUS.SUCCESS;
20 | result = res;
21 | },
22 | (err) => {
23 | status = FETCH_STATUS.ERROR;
24 | result = err;
25 | }
26 | );
27 |
28 | return {
29 | read: () => {
30 | if (status === FETCH_STATUS.PENDING) throw suspender;
31 | if (status === FETCH_STATUS.ERROR) throw result;
32 | return result as unknown as MessageDetailType;
33 | },
34 | };
35 | }
36 |
--------------------------------------------------------------------------------
/client/src/pages/MessagePage/services/fetchMessageList.ts:
--------------------------------------------------------------------------------
1 | import { MessageMetaDataType } from 'types/message';
2 | import { API, FETCH_STATUS } from 'utils/constants';
3 | import { checkCustomCode, checkStatusCode } from 'utils/fetchUtils';
4 |
5 | export function fetchMessageList() {
6 | let status = FETCH_STATUS.PENDING;
7 | let result: Error | MessageMetaDataType[];
8 |
9 | const suspender = fetch(`${process.env.REACT_APP_FETCH_URL}${API.MESSAGE_LIST}`, {
10 | credentials: 'include',
11 | method: 'get',
12 | })
13 | .then(checkStatusCode)
14 | .then(checkCustomCode)
15 | .then(
16 | (res) => {
17 | status = FETCH_STATUS.SUCCESS;
18 | result = res;
19 | },
20 | (err) => {
21 | status = FETCH_STATUS.ERROR;
22 | result = err;
23 | }
24 | );
25 |
26 | return {
27 | read: () => {
28 | if (status === FETCH_STATUS.PENDING) throw suspender;
29 | if (status === FETCH_STATUS.ERROR) throw result;
30 | return result as unknown as MessageMetaDataType[];
31 | },
32 | };
33 | }
34 |
--------------------------------------------------------------------------------
/client/src/pages/MessagePage/services/fetchSendMessage.ts:
--------------------------------------------------------------------------------
1 | import { API } from 'utils/constants';
2 | import { checkStatusCode, checkCustomCode } from 'utils/fetchUtils';
3 |
4 | export function fetchSendMessage(userId: string, content: string) {
5 | return fetch(`${process.env.REACT_APP_FETCH_URL}${API.MESSAGE_SEND}`, {
6 | credentials: 'include',
7 | method: 'post',
8 | headers: { 'Content-Type': 'application/json' },
9 | body: JSON.stringify({
10 | to: userId,
11 | content,
12 | }),
13 | })
14 | .then(checkStatusCode)
15 | .then(checkCustomCode);
16 | }
17 |
--------------------------------------------------------------------------------
/client/src/pages/MessagePage/services/index.ts:
--------------------------------------------------------------------------------
1 | export { fetchMessageList } from './fetchMessageList';
2 | export { fetchMessageDetail } from './fetchMessageDetail';
3 | export { fetchSendMessage } from './fetchSendMessage';
4 |
--------------------------------------------------------------------------------
/client/src/pages/MessagePage/styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/react';
2 |
3 | import { COLORS } from 'styles/colors';
4 | import { getMediaQuery, MEDIA_QUERY } from 'styles/mediaQuery';
5 | import { COMMON_SIZE } from 'styles/sizes';
6 |
7 | export const messagePageWrapperStyle = css({
8 | height: '69vh',
9 | paddingLeft: 30,
10 | paddingRight: 30,
11 | display: 'grid',
12 | gridTemplateColumns: 'repeat(1, minmax(0, 1fr))',
13 | gap: 30,
14 |
15 | [getMediaQuery(MEDIA_QUERY.LG)]: {
16 | gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
17 | },
18 | });
19 |
20 | export const messagePageSectionStyle = css({
21 | width: '100%',
22 | height: '100%',
23 | backgroundColor: COLORS.WHITE,
24 | borderRadius: COMMON_SIZE.BORDER_RADIUS,
25 | border: `1px solid ${COLORS.BOX_BORDER}`,
26 | padding: `${COMMON_SIZE.PROFILE_BOX_PADDING_VERTICAL}px ${COMMON_SIZE.PROFILE_BOX_PADDING_HORIZONTAL}px`,
27 | gridColumn: '1 / 2',
28 | gridRow: '1 / 2',
29 | display: 'flex',
30 | flexDirection: 'column',
31 | overflow: 'hidden',
32 |
33 | [getMediaQuery(MEDIA_QUERY.LG)]: {
34 | gridColumn: 'initial',
35 | gridRow: 'initial',
36 | },
37 | });
38 |
--------------------------------------------------------------------------------
/client/src/pages/RegisterPage/fetchRequestRegistration.ts:
--------------------------------------------------------------------------------
1 | import { API } from 'utils/constants';
2 | import { checkCustomCode, checkStatusCode } from 'utils/fetchUtils';
3 |
4 | interface registerParams {
5 | username: string;
6 | interest: string;
7 | techStack: Array;
8 | }
9 |
10 | export function fetchRequestRegistration({ username, interest, techStack }: registerParams) {
11 | return fetch(`${process.env.REACT_APP_FETCH_URL}${API.REGISTER}`, {
12 | credentials: 'include',
13 | method: 'post',
14 | headers: { 'Content-Type': 'application/json' },
15 | body: JSON.stringify({ username, interest, techStack }),
16 | })
17 | .then(checkStatusCode)
18 | .then(checkCustomCode);
19 | }
20 |
--------------------------------------------------------------------------------
/client/src/pages/RegisterPage/index.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import { useEffect, useState } from 'react';
4 | import { useNavigate } from 'react-router-dom';
5 |
6 | import { Button, InterestInput, TechStackInput } from 'common';
7 | import { UsernameInput } from './usernameInput';
8 | import { fetchRequestRegistration } from './fetchRequestRegistration';
9 | import { LINK } from 'utils/constants';
10 |
11 | import { dropdownStyle, registerPageButtonStyle, registerPageHeaderStyle, registerPageSubHeaderStyle } from './styles';
12 |
13 | export const RegisterPage = () => {
14 | const nav = useNavigate();
15 | const [username, setUsername] = useState('');
16 | const [interest, setInterest] = useState('');
17 | const [techStack, setTechStack] = useState>([]);
18 | const [isAllSet, setIsAllSet] = useState(false);
19 |
20 | // deps가 많아, 굳이 useCallback 처리가 필요없다고 사료됨
21 | const handleClickRegisterButton = () => {
22 | if (!isAllSet) return;
23 | fetchRequestRegistration({ username, interest, techStack })
24 | .then(() => {
25 | alert('회원가입에 성공하였습니다.');
26 | nav(LINK.LOGIN);
27 | })
28 | .catch((err) => alert(err));
29 | };
30 |
31 | // 모든 입력값이 입력되었는지 검사
32 | useEffect(() => {
33 | setIsAllSet(username.length > 0 && interest.length > 0);
34 | }, [username, interest, techStack]);
35 |
36 | return (
37 | <>
38 | 5분이면 충분해요.
39 | 파트너를 찾기 위한 정보를 알려주세요!
40 |
41 |
42 |
43 |
51 | >
52 | );
53 | };
54 |
--------------------------------------------------------------------------------
/client/src/pages/RegisterPage/styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/react';
2 |
3 | import { COLORS } from 'styles/colors';
4 | import { FONT_SIZE, LOGIN_SIZE } from 'styles/sizes';
5 |
6 | export const registerPageHeaderStyle = css({
7 | fontSize: FONT_SIZE.LARGE,
8 | color: COLORS.TEXT_1,
9 | textAlign: 'center',
10 | marginBottom: 10,
11 | });
12 |
13 | export const registerPageSubHeaderStyle = css({
14 | fontSize: FONT_SIZE.MEDIUM,
15 | color: COLORS.TEXT_1,
16 | textAlign: 'center',
17 | marginBottom: 20,
18 | });
19 |
20 | export const dropdownStyle = css({
21 | width: '60%',
22 | marginBottom: LOGIN_SIZE.INPUT_MARGIN_BOTTOM,
23 | });
24 |
25 | export const registerPageButtonStyle = (isAllSet: boolean) =>
26 | css({
27 | padding: `10px 70px`,
28 | opacity: isAllSet ? 1 : 0.5,
29 |
30 | ' span': {
31 | fontSize: FONT_SIZE.LARGE,
32 | fontWeight: '600',
33 | },
34 |
35 | ':hover': {
36 | backgroundColor: isAllSet ? COLORS.PRIMARY_2 : COLORS.PRIMARY_1,
37 | cursor: isAllSet ? 'pointer' : 'initial',
38 | },
39 | });
40 |
--------------------------------------------------------------------------------
/client/src/pages/RegisterPage/usernameInput.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/react';
2 |
3 | import { VALIDATION_RESULT } from 'utils/constants';
4 |
5 | import { COLORS } from 'styles/colors';
6 | import { FONT_SIZE, LOGIN_SIZE } from 'styles/sizes';
7 | import { getMediaQuery, MEDIA_QUERY } from 'styles/mediaQuery';
8 |
9 | export const usernameInputWrapperStyle = (validationType: number) =>
10 | css({
11 | width: '60%',
12 | display: 'flex',
13 | flexDirection: 'row',
14 | alignItems: 'center',
15 | justifyContent: 'center',
16 | position: 'relative',
17 | marginBottom:
18 | validationType !== VALIDATION_RESULT.NULL
19 | ? (LOGIN_SIZE.INPUT_MARGIN_BOTTOM - FONT_SIZE.SMALL) / 2
20 | : LOGIN_SIZE.INPUT_MARGIN_BOTTOM,
21 | });
22 |
23 | export const usernameInputStyle = css({
24 | color: COLORS.TEXT_1,
25 | fontSize: FONT_SIZE.SMALL,
26 | padding: `${LOGIN_SIZE.INPUT_PADDING_VERTICAL - 0.5 / 2}px ${LOGIN_SIZE.INPUT_PADDING_HORIZONTAL - 0.5}px`, // 5px 10px 에서 테두리 1씩
27 | border: `1px solid ${COLORS.PRIMARY_1}`,
28 | borderRight: 'none',
29 | borderTopLeftRadius: LOGIN_SIZE.INPUT_BORDER_RADIUS,
30 | borderBottomLeftRadius: LOGIN_SIZE.INPUT_BORDER_RADIUS,
31 | display: 'flex',
32 | flexDirection: 'row',
33 | justifyContent: 'center',
34 |
35 | '::placeholder': {
36 | color: COLORS.TEXT_1,
37 | opacity: 0.5,
38 | },
39 |
40 | [getMediaQuery(MEDIA_QUERY.SM)]: {
41 | fontSize: FONT_SIZE.MEDIUM,
42 | },
43 | });
44 |
45 | export const usernameButtonStyle = css({
46 | flex: 1,
47 | backgroundColor: COLORS.PRIMARY_1,
48 | color: COLORS.LIGHT,
49 | fontSize: FONT_SIZE.SMALL,
50 | borderTopRightRadius: LOGIN_SIZE.INPUT_BORDER_RADIUS,
51 | borderBottomRightRadius: LOGIN_SIZE.INPUT_BORDER_RADIUS,
52 | padding: `${LOGIN_SIZE.INPUT_PADDING_VERTICAL}px ${LOGIN_SIZE.INPUT_PADDING_HORIZONTAL}px`,
53 | transition: `0.2s linear`,
54 | whiteSpace: 'nowrap',
55 |
56 | ':hover': {
57 | cursor: 'pointer',
58 | backgroundColor: COLORS.PRIMARY_2,
59 | },
60 |
61 | [getMediaQuery(MEDIA_QUERY.SM)]: {
62 | fontSize: FONT_SIZE.MEDIUM,
63 | },
64 | });
65 |
66 | export const usernameValidationStyle = (validationType: number) =>
67 | css({
68 | color: validationType === VALIDATION_RESULT.SUCCESS ? COLORS.SUCCESS : COLORS.FAILURE,
69 | fontSize: FONT_SIZE.SMALL,
70 | marginBottom: (LOGIN_SIZE.INPUT_MARGIN_BOTTOM - FONT_SIZE.SMALL) / 2,
71 | });
72 |
--------------------------------------------------------------------------------
/client/src/pages/RegisterPage/usernameInput.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import { Dispatch, SetStateAction } from 'react';
4 |
5 | import { VALIDATION_INFO, VALIDATION_RESULT } from 'utils/constants';
6 | import { useValidateUsername } from 'hooks';
7 |
8 | import {
9 | usernameButtonStyle,
10 | usernameInputStyle,
11 | usernameInputWrapperStyle,
12 | usernameValidationStyle,
13 | } from './usernameInput.styles';
14 |
15 | export const UsernameInput = ({ setUsername }: { setUsername: Dispatch> }) => {
16 | const { validationType, handleClickValidateButton, usernameDraft, handleChangeUsername } =
17 | useValidateUsername(setUsername);
18 |
19 | return (
20 | <>
21 |
22 |
23 |
26 |
27 | {validationType !== VALIDATION_RESULT.NULL && (
28 | {VALIDATION_INFO[validationType]}
29 | )}
30 | >
31 | );
32 | };
33 |
--------------------------------------------------------------------------------
/client/src/pages/SettingsPage/CodeBoxSelector.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/react';
2 |
3 | import { COLORS } from 'styles/colors';
4 | import { COMMON_SIZE, FONT_SIZE } from 'styles/sizes';
5 |
6 | export const codeBoxListStyle = css({
7 | display: 'flex',
8 | flexDirection: 'row',
9 | width: '100%',
10 | overflowX: 'scroll',
11 | overflowY: 'hidden',
12 | });
13 |
14 | export const codeListElementStyle = (isSelected: boolean) =>
15 | css({
16 | textAlign: 'start',
17 | padding: 0,
18 | height: 'fit-content',
19 | borderRadius: COMMON_SIZE.BORDER_RADIUS,
20 | marginRight: 10,
21 | border: isSelected ? `3px solid ${COLORS.PRIMARY_1}` : `1px solid ${COLORS.BOX_BORDER}`,
22 | display: 'flex',
23 | flexDirection: 'column',
24 | alignItems: 'center',
25 |
26 | '> span': {
27 | marginTop: 10,
28 | marginBottom: 5,
29 | fontSize: FONT_SIZE.MEDIUM,
30 | fontStyle: 'italic',
31 | color: COLORS.TEXT_2,
32 | },
33 | });
34 |
--------------------------------------------------------------------------------
/client/src/pages/SettingsPage/CodeBoxSizeSelector.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import { useRecoilValue } from 'recoil';
4 |
5 | import { settingsState } from 'store';
6 | import { MiniCodeBox } from './MiniCodeBox';
7 | import { CODE_EXAMPLE, CODE_SIZE, THEME_LIST } from 'utils/constants';
8 |
9 | import { codeBoxListStyle, codeListElementStyle } from './CodeBoxSelector.styles';
10 |
11 | interface Props {
12 | onSelect: (index: number) => void;
13 | }
14 |
15 | export const CodeBoxSizeSelector = ({ onSelect }: Props) => {
16 | const settings = useRecoilValue(settingsState);
17 | const { style } = THEME_LIST[settings.codeBoxTheme];
18 |
19 | return (
20 |
21 | {CODE_SIZE.map((option, index) => (
22 |
31 | ))}
32 |
33 | );
34 | };
35 |
--------------------------------------------------------------------------------
/client/src/pages/SettingsPage/CodeBoxThemeSelector.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import { useRecoilValue } from 'recoil';
4 |
5 | import { settingsState } from 'store';
6 | import { MiniCodeBox } from './MiniCodeBox';
7 | import { CODE_EXAMPLE, CODE_SIZE, THEME_LIST } from 'utils/constants';
8 |
9 | import { codeBoxListStyle, codeListElementStyle } from './CodeBoxSelector.styles';
10 |
11 | interface Props {
12 | onSelect: (index: number) => void;
13 | }
14 |
15 | export const CodeBoxThemeSelector = ({ onSelect }: Props) => {
16 | const settings = useRecoilValue(settingsState);
17 | const { size } = CODE_SIZE[settings.codeBoxSize];
18 |
19 | return (
20 |
21 | {THEME_LIST.map((option, index) => (
22 |
31 | ))}
32 |
33 | );
34 | };
35 |
--------------------------------------------------------------------------------
/client/src/pages/SettingsPage/MiniCodeBox.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import { CSSProperties } from 'react';
4 | import SyntaxHighlighter from 'react-syntax-highlighter';
5 |
6 | import { COMMON_SIZE } from 'styles/sizes';
7 |
8 | interface Props {
9 | code: string;
10 | style: { [key: string]: CSSProperties };
11 | fontSize: number;
12 | }
13 |
14 | export const MiniCodeBox = ({ code, style, fontSize }: Props) => {
15 | return (
16 |
23 | {code}
24 |
25 | );
26 | };
27 |
28 | const miniCodeBoxStyle = {
29 | width: 230,
30 | height: 160,
31 | borderRadius: COMMON_SIZE.BORDER_RADIUS,
32 | margin: 0,
33 | overflow: 'hidden',
34 | };
35 |
--------------------------------------------------------------------------------
/client/src/pages/SettingsPage/index.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource @emotion/react */
2 |
3 | import { useCallback, useEffect } from 'react';
4 | import { useRecoilState } from 'recoil';
5 |
6 | import { MiniNavBar, NavSubtitle, Tooltip } from 'common';
7 | import { settingsState } from 'store';
8 | import { useShowTooltip } from 'hooks';
9 | import { setSettingsInLocalStorage } from 'utils/storage';
10 | import { CodeBoxThemeSelector } from './CodeBoxThemeSelector';
11 | import { CodeBoxSizeSelector } from './CodeBoxSizeSelector';
12 |
13 | import {
14 | settingsWrapperStyle,
15 | settingsBackgroundStyle,
16 | settingsListStyle,
17 | tooltipIconStyle,
18 | subtitleWrapperStyle,
19 | } from './styles';
20 |
21 | import { QuestionIcon } from 'assets/svgs';
22 |
23 | export const SettingsPage = () => {
24 | const [settings, setSettings] = useRecoilState(settingsState);
25 | const { isTooltipShown, handleMouseOutTooltip, handleMouseOverTooltip } = useShowTooltip();
26 |
27 | const handleSelectCodeTheme = useCallback((index: number) => {
28 | setSettings((prevValue) => ({ ...prevValue, codeBoxTheme: index }));
29 | }, []);
30 |
31 | const handleSelectCodeSize = useCallback((index: number) => {
32 | setSettings((prevValue) => ({ ...prevValue, codeBoxSize: index }));
33 | }, []);
34 |
35 | useEffect(() => {
36 | setSettingsInLocalStorage(settings);
37 | }, [settings]);
38 |
39 | return (
40 | <>
41 |
42 |
43 |
44 |
51 |
52 |
53 | {isTooltipShown &&
}
54 |
55 |
56 |
57 |
58 | -
59 |
코드 뷰어 테마
60 |
61 |
62 | -
63 |
코드 글자 크기
64 |
65 |
66 | -
67 |
화면 밝기
68 | {/* TODO: 화면 밝기 토글 (다크모드 라이트모드) */}
69 |
70 |
71 |
72 | >
73 | );
74 | };
75 |
--------------------------------------------------------------------------------
/client/src/pages/SettingsPage/styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/react';
2 |
3 | import { COLORS } from 'styles/colors';
4 | import { COMMON_SIZE, FONT_SIZE } from 'styles/sizes';
5 |
6 | export const settingsBackgroundStyle = css({
7 | display: 'flex',
8 | height: '69vh',
9 | paddingLeft: 30,
10 | paddingRight: 30,
11 | });
12 |
13 | export const settingsWrapperStyle = css({
14 | width: '100%',
15 | height: '100%',
16 | borderRadius: COMMON_SIZE.BORDER_RADIUS,
17 | border: `${COMMON_SIZE.LINE_WIDTH}px solid ${COLORS.BOX_BORDER}`,
18 | backgroundColor: COLORS.WHITE,
19 | overflowX: 'hidden',
20 | overflowY: 'scroll',
21 | });
22 |
23 | export const settingsListStyle = css({
24 | padding: 20,
25 | borderBottom: `${COMMON_SIZE.LINE_WIDTH}px solid ${COLORS.BOX_BORDER}`,
26 |
27 | ' h3': {
28 | fontWeight: 600,
29 | fontSize: FONT_SIZE.LARGE,
30 | color: COLORS.TEXT_1,
31 | marginBottom: 20,
32 | },
33 |
34 | ':last-of-type': {
35 | border: 'none',
36 | },
37 | });
38 |
39 | export const subtitleWrapperStyle = css({
40 | display: 'flex',
41 | flexDirection: 'row',
42 | alignItems: 'center',
43 | position: 'relative',
44 | });
45 |
46 | export const tooltipIconStyle = css({
47 | display: 'flex',
48 | flexDirection: 'row',
49 | alignItems: 'center',
50 | justifyContent: 'center',
51 | position: 'relative',
52 | width: 20,
53 | height: 20,
54 | marginLeft: 10,
55 |
56 | ' svg': {
57 | width: 20,
58 | height: 20,
59 | fill: COLORS.BOX_BORDER,
60 | },
61 | });
62 |
--------------------------------------------------------------------------------
/client/src/pages/index.ts:
--------------------------------------------------------------------------------
1 | export { MainPage } from './MainPage';
2 | export { DetailPage } from './DetailPage';
3 | export { SettingsPage } from './SettingsPage';
4 | export { MessagePage, MessageDetail } from './MessagePage';
5 |
6 | export { LoginPage } from './LoginPage';
7 | export { RegisterPage } from './RegisterPage';
8 |
--------------------------------------------------------------------------------
/client/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/client/src/services/fetchCheckLogin.ts:
--------------------------------------------------------------------------------
1 | import { API } from 'utils/constants';
2 | import { checkCustomCode, checkStatusCode } from 'utils/fetchUtils';
3 |
4 | export function fetchCheckLogin() {
5 | return fetch(`${process.env.REACT_APP_FETCH_URL}${API.CHECK}`, {
6 | credentials: 'include',
7 | method: 'get',
8 | headers: { 'Content-Type': 'application/json' },
9 | })
10 | .then(checkStatusCode)
11 | .then(checkCustomCode);
12 | }
13 |
--------------------------------------------------------------------------------
/client/src/services/fetchLogout.ts:
--------------------------------------------------------------------------------
1 | import { API } from 'utils/constants';
2 | import { checkCustomCode, checkStatusCode } from 'utils/fetchUtils';
3 |
4 | export function fetchLogout() {
5 | return fetch(`${process.env.REACT_APP_FETCH_URL}${API.LOGOUT}`, {
6 | credentials: 'include',
7 | method: 'get',
8 | headers: { 'Content-Type': 'application/json' },
9 | })
10 | .then(checkStatusCode)
11 | .then(checkCustomCode);
12 | }
13 |
--------------------------------------------------------------------------------
/client/src/services/fetchSendLikeToServer.ts:
--------------------------------------------------------------------------------
1 | import { API } from 'utils/constants';
2 | import { checkCustomCode, checkStatusCode } from 'utils/fetchUtils';
3 |
4 | export function fetchSendLikeToServer(likedId: string, type: string) {
5 | return fetch(`${process.env.REACT_APP_FETCH_URL}${API.LIKE}`, {
6 | credentials: 'include',
7 | method: type,
8 | headers: { 'Content-Type': 'application/json' },
9 | body: JSON.stringify({ likedId }),
10 | })
11 | .then(checkStatusCode)
12 | .then(checkCustomCode)
13 | .catch((err) => {
14 | alert(err);
15 | });
16 | }
17 |
--------------------------------------------------------------------------------
/client/src/services/fetchUsernameServerValidation.ts:
--------------------------------------------------------------------------------
1 | import { API } from 'utils/constants';
2 | import { checkStatusCode } from 'utils/fetchUtils';
3 |
4 | export function fetchUsernameServerValidation(usernameDraft: string) {
5 | // 서버측 id 유효성 검사를 위해 fetch 통신(쿼리스트링)
6 | return fetch(
7 | `${process.env.REACT_APP_FETCH_URL}${API.VALIDATE}?${new URLSearchParams({ username: usernameDraft })}`
8 | ).then(checkStatusCode);
9 | }
10 |
--------------------------------------------------------------------------------
/client/src/services/index.ts:
--------------------------------------------------------------------------------
1 | export { fetchCheckLogin } from './fetchCheckLogin';
2 | export { fetchLogout } from './fetchLogout';
3 | export { fetchUsernameServerValidation } from './fetchUsernameServerValidation';
4 | export { fetchSendLikeToServer } from './fetchSendLikeToServer';
5 |
--------------------------------------------------------------------------------
/client/src/store/currentUserState.ts:
--------------------------------------------------------------------------------
1 | import { atom } from 'recoil';
2 |
3 | import { AuthType } from 'types/auth';
4 |
5 | export const currentUserState = atom({
6 | key: 'currentUserState',
7 | default: { id: null },
8 | });
9 |
--------------------------------------------------------------------------------
/client/src/store/index.ts:
--------------------------------------------------------------------------------
1 | // export {atom 이름} from '파일경로'
2 | export { currentUserState } from './currentUserState';
3 | export { settingsState } from './settingsState';
4 | export { isNewMessageState } from './isNewMessageState';
5 | export { newMessageState } from './newMessageState';
6 |
--------------------------------------------------------------------------------
/client/src/store/isNewMessageState.ts:
--------------------------------------------------------------------------------
1 | import { atom } from 'recoil';
2 |
3 | export const isNewMessageState = atom({
4 | key: 'isNewMessageState',
5 | default: false,
6 | });
7 |
--------------------------------------------------------------------------------
/client/src/store/newMessageState.ts:
--------------------------------------------------------------------------------
1 | import { atom } from 'recoil';
2 |
3 | import { SingleMessageType } from 'types/message';
4 |
5 | export const newMessageState = atom({
6 | key: 'newMessageState',
7 | default: null,
8 | });
9 |
--------------------------------------------------------------------------------
/client/src/store/settingsState.ts:
--------------------------------------------------------------------------------
1 | import { atom } from 'recoil';
2 |
3 | import { SettingsType } from 'types/settings';
4 |
5 | export const settingsState = atom({
6 | key: 'settingsState',
7 | default: {
8 | codeBoxSize: 0,
9 | codeBoxTheme: 0,
10 | isDarkMode: false,
11 | },
12 | });
13 |
--------------------------------------------------------------------------------
/client/src/styles/colors.ts:
--------------------------------------------------------------------------------
1 | export const COLORS = {
2 | PRIMARY_1: '#aac4ff',
3 | PRIMARY_2: '#93adff',
4 | PRIMARY_DIM: `#aac4ff30`,
5 | BOX_BORDER: '#d2daff',
6 | LIGHT: '#eef1ff',
7 | DARK: '#949ab3',
8 |
9 | SUCCESS: '#87eb95',
10 | FAILURE: '#d33a55',
11 |
12 | TEXT_1: '#2c2c40',
13 | TEXT_2: '#585980',
14 | WHITE: '#ffffff',
15 | BLACK: '#000000',
16 | SHADOW: '#2F478020',
17 |
18 | SCROLLBAR_COLOR: '#E0E0E0',
19 | SCROLL_BG_COLOR: '#F5F5F7',
20 | };
21 |
--------------------------------------------------------------------------------
/client/src/styles/mediaQuery.ts:
--------------------------------------------------------------------------------
1 | export const MEDIA_QUERY = {
2 | XS: 0,
3 | SM: 576,
4 | MD: 768,
5 | LG: 992,
6 | };
7 |
8 | // https://getbootstrap.com/docs/5.0/layout/breakpoints/
9 | // 핸드폰 기준: 통상적으로 576px까지 (XS)
10 | // 태블릿 기준: 통상적으로 768px까지 (SM ~ MD)
11 | // 그 이상은 보통 PC 및 모니터로 취급 (LG)
12 |
13 | export function getMediaQuery(minWidth: number, maxWidth?: number): string {
14 | const mediaOnly = '@media only screen ';
15 | const minWidthQuery = minWidth ? `and (min-width: ${minWidth}px)` : '';
16 | const maxWidthQuery = maxWidth ? `and (max-width: ${maxWidth}px)` : '';
17 |
18 | return mediaOnly + minWidthQuery + maxWidthQuery;
19 | }
20 |
--------------------------------------------------------------------------------
/client/src/styles/sizes.ts:
--------------------------------------------------------------------------------
1 | export const FONT_SIZE = {
2 | // 타이틀, 로고용
3 | TITLE_48: 48,
4 | TITLE_32: 32,
5 | TITLE_24: 24,
6 |
7 | // 일반 폰트용
8 | LARGE: 20,
9 | MEDIUM: 16,
10 | SMALL: 13,
11 | };
12 |
13 | // VERTICAL: 정중앙
14 |
15 | export const COMMON_SIZE = {
16 | BORDER_RADIUS: 5, // 전체 박스의 모서리 둥글기
17 |
18 | LINE_WIDTH: 1, // 관심분야 리스트에 쓰는 구분선
19 |
20 | PROFILE_BOX_PADDING_HORIZONTAL: 40,
21 | PROFILE_BOX_PADDING_VERTICAL: 20,
22 | CODE_BOX_PADDING: 20,
23 | PROFILE_BOX_DT_MARGIN_BOTTOM: 10,
24 | PROFILE_BOX_DD_MARGIN_BOTTOM: 20,
25 |
26 | EDITOR_BOX_INPUT_BORDER_RADIUS: 5,
27 | EDITOR_BOX_INPUT_HEIGHT: 20,
28 |
29 | SCROLLBAR_WIDTH: 10, // 메인화면 프로필 리스트 스크롤바
30 |
31 | PROFILELIST_SINGLE_WIDTH: 830,
32 | PROFILELIST_TRIPLE_WIDTH: 1190,
33 | };
34 |
35 | export const LOGIN_SIZE = {
36 | // Register Page
37 | INPUT_PADDING_HORIZONTAL: 10,
38 | INPUT_PADDING_VERTICAL: 5,
39 | INPUT_BORDER_RADIUS: 5,
40 | INPUT_MARGIN_BOTTOM: 30,
41 | };
42 |
43 | export const LOGO_SIZE = {
44 | MAIN_LOGO_WIDTH: '133.88',
45 | MAIN_LOGO_HEIGHT: '30',
46 | LOGIN_LOGO_WIDTH: '160',
47 | LOGIN_LOGO_HEIGHT: '35.86',
48 | };
49 |
--------------------------------------------------------------------------------
/client/src/types/auth.d.ts:
--------------------------------------------------------------------------------
1 | export interface AuthType {
2 | id: string | null;
3 | }
4 |
--------------------------------------------------------------------------------
/client/src/types/message.d.ts:
--------------------------------------------------------------------------------
1 | export interface MessageMetaDataType {
2 | with: string;
3 | username: string;
4 | lastCheckTime: string;
5 | }
6 |
7 | export interface MessageDetailType {
8 | contents: SingleMessageType[];
9 | toUsername: string;
10 | }
11 |
12 | export interface SingleMessageType {
13 | from: string;
14 | content: string;
15 | createdAt: string;
16 | }
17 |
--------------------------------------------------------------------------------
/client/src/types/profile.d.ts:
--------------------------------------------------------------------------------
1 | export interface ProfileType {
2 | username: string;
3 | code: string;
4 | language: string;
5 | interest: string;
6 | techStack: string[];
7 | worktype: string;
8 | worktime: string;
9 | email?: string;
10 | requirements: string[];
11 | liked?: boolean;
12 | }
13 |
14 | export interface SingleProfileType {
15 | id: string;
16 | language: string;
17 | code: string;
18 | interest: string;
19 | techStack: Array;
20 | requirements: Array;
21 | liked: boolean;
22 | }
23 |
--------------------------------------------------------------------------------
/client/src/types/settings.d.ts:
--------------------------------------------------------------------------------
1 | export interface SettingsType {
2 | codeBoxTheme: number;
3 | codeBoxSize: number;
4 | isDarkMode: boolean;
5 | }
6 |
--------------------------------------------------------------------------------
/client/src/utils/fetchUtils.ts:
--------------------------------------------------------------------------------
1 | import { COMMON_ERROR } from './constants';
2 |
3 | interface JSONResult {
4 | code: number;
5 | message: string;
6 | data?: any;
7 | }
8 |
9 | export function checkStatusCode(res: Response) {
10 | return res.json().then((data) => {
11 | if (res.status < 400) return data;
12 | if (data.message) throw new Error(data.message);
13 | throw new Error(COMMON_ERROR);
14 | });
15 | }
16 |
17 | export function checkCustomCode(res: JSONResult) {
18 | if (res.code !== 10000) throw new Error(res.message);
19 | return res.data;
20 | }
21 |
22 | // FOR DEBUG
23 | export function setFetchDelay(ms: number) {
24 | return (x: any) => {
25 | return new Promise((resolve) => {
26 | setTimeout(() => resolve(x), ms);
27 | });
28 | };
29 | }
30 |
--------------------------------------------------------------------------------
/client/src/utils/storage.ts:
--------------------------------------------------------------------------------
1 | import { SettingsType } from 'types/settings';
2 |
3 | export function setSettingsInLocalStorage(settingsObj: SettingsType): void {
4 | localStorage.setItem('settings', JSON.stringify(settingsObj));
5 | }
6 |
7 | export function getSettingsFromLocalStorage(): SettingsType | null {
8 | try {
9 | const settingsStr = localStorage.getItem('settings');
10 | if (!settingsStr) return null;
11 | return JSON.parse(settingsStr);
12 | } catch {
13 | return null;
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/client/src/utils/usernameValidation.ts:
--------------------------------------------------------------------------------
1 | // 클라이언트측 id 유효성 검사
2 | // 아이디 요소 확인
3 | export function isValidUsernameStr(id: string) {
4 | const regexEngNum = /^[a-zA-Z0-9]*$/;
5 | return regexEngNum.test(id);
6 | }
7 |
8 | // 아이디 길이 확인
9 | export function isValidUsernameLength(id: string) {
10 | if (id.length === 0) return true;
11 | return id.length >= 4 && id.length <= 15;
12 | }
13 |
14 | // 아이디 유효성 검사
15 | export function isValidUsername(id: string) {
16 | if (!isValidUsernameLength(id)) return false;
17 | return isValidUsernameStr(id);
18 | }
19 |
--------------------------------------------------------------------------------
/client/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": "src",
4 | "target": "es6",
5 | "lib": ["dom", "dom.iterable", "esnext"],
6 | "allowJs": true,
7 | "skipLibCheck": true,
8 | "esModuleInterop": true,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "noFallthroughCasesInSwitch": true,
13 | "module": "esnext",
14 | "moduleResolution": "node",
15 | "resolveJsonModule": true,
16 | "isolatedModules": true,
17 | "noEmit": true,
18 | "jsx": "react-jsx"
19 | },
20 | "include": ["src"],
21 | "exclude": ["node_modules", ".next"]
22 | }
23 |
--------------------------------------------------------------------------------
/server/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | parser: '@typescript-eslint/parser',
3 | parserOptions: {
4 | project: 'tsconfig.json',
5 | tsconfigRootDir: __dirname,
6 | sourceType: 'module',
7 | },
8 | plugins: ['@typescript-eslint/eslint-plugin'],
9 | extends: [
10 | 'plugin:@typescript-eslint/recommended',
11 | 'plugin:prettier/recommended',
12 | ],
13 | root: true,
14 | env: {
15 | node: true,
16 | jest: true,
17 | },
18 | ignorePatterns: ['.eslintrc.js'],
19 | rules: {
20 | '@typescript-eslint/interface-name-prefix': 'off',
21 | '@typescript-eslint/explicit-function-return-type': 'off',
22 | '@typescript-eslint/explicit-module-boundary-types': 'off',
23 | '@typescript-eslint/no-explicit-any': 'off',
24 | 'prettier/prettier': [
25 | 'error',
26 | {
27 | endOfLine: 'auto',
28 | },
29 | ],
30 | },
31 | };
32 |
--------------------------------------------------------------------------------
/server/.gitignore:
--------------------------------------------------------------------------------
1 | # compiled output
2 | /dist
3 | /node_modules
4 |
5 | .env*
6 |
7 | # Logs
8 | logs
9 | *.log
10 | npm-debug.log*
11 | pnpm-debug.log*
12 | yarn-debug.log*
13 | yarn-error.log*
14 | lerna-debug.log*
15 |
16 | # OS
17 | .DS_Store
18 |
19 | # Tests
20 | /coverage
21 | /.nyc_output
22 |
23 | # IDEs and editors
24 | /.idea
25 | .project
26 | .classpath
27 | .c9/
28 | *.launch
29 | .settings/
30 | *.sublime-workspace
31 |
32 | # IDE - VSCode
33 | .vscode/*
34 | !.vscode/settings.json
35 | !.vscode/tasks.json
36 | !.vscode/launch.json
37 | !.vscode/extensions.json
--------------------------------------------------------------------------------
/server/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "semi": true,
4 | "trailingComma": "all"
5 | }
6 |
--------------------------------------------------------------------------------
/server/ecosystem.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | apps: [
3 | {
4 | name: 'scopa',
5 | script: './dist/main.js',
6 | },
7 | ],
8 | };
9 |
--------------------------------------------------------------------------------
/server/nest-cli.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/nest-cli",
3 | "collection": "@nestjs/schematics",
4 | "sourceRoot": "src"
5 | }
6 |
--------------------------------------------------------------------------------
/server/src/app.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { ConfigModule } from '@nestjs/config';
3 | import { MongooseModule } from '@nestjs/mongoose';
4 |
5 | import { AuthModule } from './auth/auth.module';
6 | import { UserModule } from './user/user.module';
7 | import { LikeModule } from './like/like.module';
8 | import { MessageModule } from './message/message.module';
9 |
10 | @Module({
11 | imports: [
12 | ConfigModule.forRoot({
13 | isGlobal: true,
14 | envFilePath: '.env',
15 | }),
16 | MongooseModule.forRoot(process.env.MONGO_URL, {
17 | dbName: process.env.DB_NAME,
18 | }),
19 | AuthModule,
20 | UserModule,
21 | LikeModule,
22 | MessageModule,
23 | ],
24 | })
25 | export class AppModule {}
26 |
--------------------------------------------------------------------------------
/server/src/auth/auth.controller.spec.ts:
--------------------------------------------------------------------------------
1 | import { Test, TestingModule } from '@nestjs/testing';
2 |
3 | import { AuthService } from './auth.service';
4 | import { AuthController } from './auth.controller';
5 | import { UserService } from 'src/user/user.service';
6 |
7 | describe('AuthController', () => {
8 | let controller: AuthController;
9 |
10 | beforeEach(async () => {
11 | const module: TestingModule = await Test.createTestingModule({
12 | controllers: [AuthController],
13 | providers: [
14 | { provide: AuthService, useValue: {} },
15 | {
16 | provide: UserService,
17 | useValue: {},
18 | },
19 | ],
20 | }).compile();
21 |
22 | controller = module.get(AuthController);
23 | });
24 |
25 | it('should be defined', () => {
26 | expect(controller).toBeDefined();
27 | });
28 | });
29 |
--------------------------------------------------------------------------------
/server/src/auth/auth.controller.ts:
--------------------------------------------------------------------------------
1 | import { Controller, Get, Query, Redirect, Res, Session } from '@nestjs/common';
2 | import { Response } from 'express';
3 |
4 | import { SessionInfo } from 'src/common/d';
5 | import { AuthService } from './auth.service';
6 | import { errors, SuccessResponse } from 'src/common/response/index';
7 | import { UserService } from 'src/user/user.service';
8 |
9 | @Controller('/api/auth')
10 | export class AuthController {
11 | constructor(
12 | private readonly authService: AuthService,
13 | private readonly userService: UserService,
14 | ) {}
15 |
16 | @Get('/google-callback')
17 | @Redirect()
18 | async GoogleCallback(
19 | @Query('code') code: string,
20 | @Session() session: SessionInfo,
21 | ) {
22 | const authInfo = await this.authService.getGoogleInfo(code);
23 |
24 | //DB에서 유저 확인
25 | const user = await this.userService.findUserByAuth(
26 | authInfo.authProvider,
27 | authInfo.authId,
28 | );
29 |
30 | if (!user) {
31 | session.authInfo = authInfo;
32 | return { url: `${process.env.CLIENT_URL}/register` };
33 | }
34 | //세션에 사용자 정보 저장(로그인)
35 | session.userId = user._id.toString();
36 | return { url: `${process.env.CLIENT_URL}` };
37 | }
38 |
39 | @Get('/github-callback')
40 | @Redirect()
41 | async GithubCallback(
42 | @Query('code') code: string,
43 | @Query('error') error: string,
44 | @Session() session: SessionInfo,
45 | ) {
46 | if (error === 'access_denied') {
47 | return { url: `${process.env.CLIENT_URL}/login` };
48 | }
49 | const authInfo = await this.authService.getGithubInfo(code);
50 |
51 | //DB에서 유저 확인
52 | const user = await this.userService.findUserByAuth(
53 | authInfo.authProvider,
54 | authInfo.authId,
55 | );
56 |
57 | if (!user) {
58 | session.authInfo = authInfo;
59 | return { url: `${process.env.CLIENT_URL}/register` };
60 | }
61 | //세션에 사용자 정보 저장(로그인)
62 | session.userId = user._id.toString();
63 | return { url: `${process.env.CLIENT_URL}` };
64 | }
65 |
66 | @Get('/check')
67 | checkUser(@Session() session: SessionInfo) {
68 | if (!session.userId) {
69 | throw errors.NOT_LOGGED_IN;
70 | }
71 | return new SuccessResponse({ id: session.userId });
72 | }
73 |
74 | @Get('/logout')
75 | logout(@Session() session: Record, @Res() res: Response) {
76 | session.destroy();
77 | res.clearCookie('connect.sid');
78 | res.status(200).send(new SuccessResponse());
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/server/src/auth/auth.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 |
3 | import { AuthService } from './auth.service';
4 | import { AuthController } from './auth.controller';
5 | import { UserModule } from 'src/user/user.module';
6 |
7 | @Module({
8 | imports: [UserModule],
9 | controllers: [AuthController],
10 | providers: [AuthService],
11 | })
12 | export class AuthModule {}
13 |
--------------------------------------------------------------------------------
/server/src/auth/auth.service.spec.ts:
--------------------------------------------------------------------------------
1 | import { Test, TestingModule } from '@nestjs/testing';
2 |
3 | import { AuthService } from './auth.service';
4 |
5 | describe('AuthService', () => {
6 | let service: AuthService;
7 |
8 | beforeEach(async () => {
9 | const module: TestingModule = await Test.createTestingModule({
10 | providers: [AuthService],
11 | }).compile();
12 |
13 | service = module.get(AuthService);
14 | });
15 |
16 | it('should be defined', () => {
17 | expect(service).toBeDefined();
18 | });
19 | });
20 |
--------------------------------------------------------------------------------
/server/src/common/base-entity.ts:
--------------------------------------------------------------------------------
1 | import mongoose from 'mongoose';
2 |
3 | export class BaseEntity {
4 | readonly _id: mongoose.Types.ObjectId;
5 | readonly createdAt: string;
6 | readonly updatedAt: string;
7 | }
8 |
--------------------------------------------------------------------------------
/server/src/common/d.ts:
--------------------------------------------------------------------------------
1 | import { HttpStatus } from '@nestjs/common';
2 |
3 | export interface AuthInfo {
4 | authProvider: string;
5 | authId: string;
6 | }
7 |
8 | export interface SessionInfo {
9 | authInfo?: AuthInfo;
10 | userId?: string;
11 | }
12 |
13 | export type ErrorInfo = [number, string, HttpStatus];
14 |
15 | export interface ErrorResponse {
16 | code: number;
17 | message: string | Record;
18 | }
19 |
--------------------------------------------------------------------------------
/server/src/common/enum.ts:
--------------------------------------------------------------------------------
1 | export enum TechStack {
2 | REACT = 'React',
3 | REDUX = 'Redux',
4 | RECOIL = 'Recoil',
5 | EMOTION = 'Emotion',
6 | TAILWIND = 'Tailwind',
7 | HTML = 'HTML',
8 | JAVASCRIPT = 'JavaScript',
9 | TYPESCRIPT = 'TypeScript',
10 | VUE = 'Vue',
11 | ANGULAR = 'Angular',
12 | C_CPP = 'C/C++',
13 | PYTHON = 'Python',
14 | JAVA = 'Java',
15 | NEXTJS = 'Next.js',
16 | FLUTTER = 'Flutter',
17 | DJANGO = 'Django',
18 | MONGODB = 'MongoDB',
19 | MYSQL = 'MySQL',
20 | SWIFT = 'Swift',
21 | KOTLIN = 'Kotlin',
22 | }
23 |
24 | export enum Interest {
25 | FRONTEND = 'Frontend',
26 | BACKEND = 'Backend',
27 | IOS = 'iOS',
28 | ANDROID = 'Android',
29 | }
30 |
31 | export enum Language {
32 | JAVASCRIPT = 'javascript',
33 | TYPESCRIPT = 'typescript',
34 | JAVA = 'java',
35 | C_CPP = 'cpp',
36 | PYTHON = 'python',
37 | }
38 |
--------------------------------------------------------------------------------
/server/src/common/http-execption-filter.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ArgumentsHost,
3 | Catch,
4 | ExceptionFilter,
5 | HttpException,
6 | } from '@nestjs/common';
7 | import { Request, Response } from 'express';
8 |
9 | import { ErrorInfo } from 'src/common/d';
10 | import { CustomException, errors } from './response/index';
11 |
12 | /**
13 | * 모든 exception을 처리하는 필터
14 | * throw 된 것인 ErrorInfo, HttpException 혹은 그 외 타입인지 판단하여 CustomException 생성 후 응답 객체로 변환
15 | */
16 | @Catch()
17 | export class HttpExceptionFilter implements ExceptionFilter {
18 | catch(exception: Error | ErrorInfo, host: ArgumentsHost) {
19 | const ctx = host.switchToHttp();
20 | const res = ctx.getResponse();
21 | const req = ctx.getRequest();
22 |
23 | const customException = this.getCustomException(exception);
24 | const response = customException.getErrorResponse();
25 |
26 | const log = {
27 | timestamp: new Date(),
28 | url: req.url,
29 | response,
30 | };
31 |
32 | console.log(log);
33 |
34 | res.status(customException.getStatus()).json(response);
35 | }
36 |
37 | private isErrorInfoType(exception: Error | ErrorInfo) {
38 | return (
39 | exception instanceof Array &&
40 | exception.length === 3 &&
41 | typeof exception[0] === 'number' &&
42 | typeof exception[1] === 'string' &&
43 | typeof exception[2] === 'number'
44 | );
45 | }
46 |
47 | private getCustomException(exception: Error | ErrorInfo): CustomException {
48 | const UNDEFIND_CODE = 99999;
49 |
50 | // 직접 정의한 에러 처리(ErrorInfo)
51 | if (this.isErrorInfoType(exception)) {
52 | return new CustomException(...(exception as ErrorInfo));
53 | }
54 | console.log((exception as Error)?.stack);
55 | // 이외 build in exception 혹은 custom exception
56 | if (exception instanceof HttpException) {
57 | return new CustomException(
58 | exception instanceof CustomException
59 | ? exception.getCode()
60 | : UNDEFIND_CODE,
61 | (exception.getResponse() as Record)?.message,
62 | exception.getStatus(),
63 | );
64 | }
65 | // 처리하지 못한 모든 오류는 internal server error
66 | return new CustomException(...errors.INTERNER_ERROR);
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/server/src/common/response/error-response.ts:
--------------------------------------------------------------------------------
1 | import { HttpException, HttpStatus } from '@nestjs/common';
2 |
3 | import { ErrorInfo, ErrorResponse } from '../d';
4 |
5 | export class CustomException extends HttpException {
6 | private readonly code: number;
7 | constructor(
8 | code: number,
9 | message: string | Record,
10 | statusCode: number,
11 | ) {
12 | super(message, statusCode);
13 | this.code = code;
14 | }
15 |
16 | getCode(): number {
17 | return this.code;
18 | }
19 |
20 | getErrorResponse(): ErrorResponse {
21 | return {
22 | code: this.code,
23 | message: this.getResponse(),
24 | };
25 | }
26 | }
27 |
28 | export const errors: { readonly [key: string]: ErrorInfo } = {
29 | INTERNER_ERROR: [
30 | 99999,
31 | '내부 오류가 발생했습니다.',
32 | HttpStatus.INTERNAL_SERVER_ERROR,
33 | ],
34 | INVALID_ID: [20001, '유효하지 않은 ID 입니다.', HttpStatus.BAD_REQUEST],
35 | ID_DUPLICATED: [20002, '중복된 ID 입니다.', HttpStatus.BAD_REQUEST],
36 | NOT_LOGGED_IN: [20003, '로그인 상태가 아닙니다.', HttpStatus.UNAUTHORIZED],
37 | REGIST_FAIL: [20004, '회원가입 실패', HttpStatus.BAD_REQUEST],
38 | INVALID_AUTH_CODE: [
39 | 20005,
40 | '유효하지 않은 authorization code입니다.',
41 | HttpStatus.UNAUTHORIZED,
42 | ],
43 | NOT_MATCHED_USER: [
44 | 20006,
45 | '일치하는 유저 정보가 없습니다.',
46 | HttpStatus.BAD_REQUEST,
47 | ],
48 | LOGGED_IN: [20007, '이미 로그인 했습니다.', HttpStatus.UNAUTHORIZED],
49 | NOT_OAUTH_LOGGED_IN: [
50 | 20008,
51 | '소셜 로그인이 필요합니다.',
52 | HttpStatus.UNAUTHORIZED,
53 | ],
54 | INVALID_SESSION: [
55 | 20009,
56 | '유효하지 않은 세션입니다.',
57 | HttpStatus.UNAUTHORIZED,
58 | ],
59 | ALREADY_EXIST_ID: [
60 | 40001,
61 | '이미 좋아요 리스트에 존재하는 사용자입니다.',
62 | HttpStatus.BAD_REQUEST,
63 | ],
64 | NOT_MACHED_LIKE: [
65 | 40002,
66 | '일치하는 좋아요가 없습니다.',
67 | HttpStatus.BAD_REQUEST,
68 | ],
69 | };
70 |
--------------------------------------------------------------------------------
/server/src/common/response/index.ts:
--------------------------------------------------------------------------------
1 | export * from './error-response';
2 | export * from './success-response';
3 |
--------------------------------------------------------------------------------
/server/src/common/response/success-response.ts:
--------------------------------------------------------------------------------
1 | export class SuccessResponse {
2 | private readonly code: number;
3 | private readonly message: string;
4 | private readonly data?: T;
5 |
6 | constructor(data?: T, code = 10000, message = '성공') {
7 | this.code = code;
8 | this.message = message;
9 | this.data = data;
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/server/src/like/dto/add-like.dto.ts:
--------------------------------------------------------------------------------
1 | import { IsString } from 'class-validator';
2 |
3 | export class AddLikeRequest {
4 | @IsString()
5 | likedId: string;
6 | }
7 |
--------------------------------------------------------------------------------
/server/src/like/dto/delete-like.dto.ts:
--------------------------------------------------------------------------------
1 | import { IsString } from 'class-validator';
2 |
3 | export class DeleteLikeRequest {
4 | @IsString()
5 | likedId: string;
6 | }
7 |
--------------------------------------------------------------------------------
/server/src/like/entities/like.entity.ts:
--------------------------------------------------------------------------------
1 | import { HydratedDocument } from 'mongoose';
2 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
3 | import { IsArray, IsString } from 'class-validator';
4 |
5 | import { BaseEntity } from 'src/common/base-entity';
6 |
7 | export type LikeDocument = HydratedDocument;
8 |
9 | @Schema({
10 | versionKey: false,
11 | timestamps: true,
12 | })
13 | export class Like extends BaseEntity {
14 | @Prop({
15 | required: true,
16 | })
17 | @IsString()
18 | userId: string;
19 |
20 | @Prop({
21 | required: true,
22 | })
23 | @IsArray()
24 | likedIds: string[];
25 | }
26 |
27 | export const likeSchema = SchemaFactory.createForClass(Like);
28 |
--------------------------------------------------------------------------------
/server/src/like/like.controller.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Body,
3 | Controller,
4 | Delete,
5 | HttpCode,
6 | HttpStatus,
7 | Post,
8 | Session,
9 | } from '@nestjs/common';
10 |
11 | import { LikeService } from './like.service';
12 | import { SuccessResponse, errors } from 'src/common/response/index';
13 | import { AddLikeRequest } from './dto/add-like.dto';
14 |
15 | @Controller('/api/like')
16 | export class LikeController {
17 | constructor(private readonly likeService: LikeService) {}
18 |
19 | @Post()
20 | @HttpCode(HttpStatus.OK)
21 | async addLike(
22 | @Body() likeDto: AddLikeRequest,
23 | @Session() session: Record,
24 | ) {
25 | if (!session?.userId) {
26 | throw errors.NOT_LOGGED_IN;
27 | }
28 |
29 | await this.likeService.addLike(likeDto, session.userId);
30 |
31 | return new SuccessResponse();
32 | }
33 |
34 | @Delete()
35 | @HttpCode(HttpStatus.OK)
36 | async deleteLike(
37 | @Body() likeDto: AddLikeRequest,
38 | @Session() session: Record,
39 | ) {
40 | if (!session?.userId) {
41 | throw errors.NOT_LOGGED_IN;
42 | }
43 |
44 | await this.likeService.deleteLike(likeDto, session.userId);
45 |
46 | return new SuccessResponse();
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/server/src/like/like.module.ts:
--------------------------------------------------------------------------------
1 | import { forwardRef, Module } from '@nestjs/common';
2 | import { MongooseModule } from '@nestjs/mongoose';
3 |
4 | import { LikeService } from './like.service';
5 | import { LikeController } from './like.controller';
6 | import { LikeRepository } from './like.repository';
7 | import { Like, likeSchema } from './entities/like.entity';
8 | import { UserModule } from 'src/user/user.module';
9 |
10 | @Module({
11 | imports: [
12 | MongooseModule.forFeature([{ name: Like.name, schema: likeSchema }]),
13 | forwardRef(() => UserModule),
14 | ],
15 | controllers: [LikeController],
16 | providers: [LikeService, LikeRepository],
17 | exports: [LikeService, LikeRepository],
18 | })
19 | export class LikeModule {}
20 |
--------------------------------------------------------------------------------
/server/src/like/like.repository.ts:
--------------------------------------------------------------------------------
1 | import { Model } from 'mongoose';
2 | import { Injectable } from '@nestjs/common';
3 | import { InjectModel } from '@nestjs/mongoose';
4 |
5 | import { Like, LikeDocument } from './entities/like.entity';
6 |
7 | @Injectable()
8 | export class LikeRepository {
9 | constructor(@InjectModel(Like.name) private likeModel: Model) {}
10 |
11 | async create(userId: string): Promise {
12 | return await this.likeModel.create({ userId, likedIds: [] });
13 | }
14 |
15 | async findByUserId(userId: string): Promise {
16 | return await this.likeModel.findOne().where('userId').equals(userId);
17 | }
18 |
19 | async updateByLikedIds(userId: string, likedIds: string[]): Promise