├── .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 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /client/src/assets/svgs/checkIcon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /client/src/assets/svgs/cryIcon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | 10 | 12 | 13 | 14 | 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 | ionicons-v5-n -------------------------------------------------------------------------------- /client/src/assets/svgs/githubIcon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/assets/svgs/googleIcon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 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 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /client/src/assets/svgs/xIcon.svg: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | 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 |
17 | copyright-earlybird 18 | © 2022 Team Earlybird 19 | copyright-earlybird 20 |
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 | loading earlybird 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 | scopa logo 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 |
    31 |

    저는 이런 요구사항이 있어요

    32 |
    33 | 34 | 42 | 43 | 51 | 52 |
    53 | 61 | 69 |
    70 |
    71 |
    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 |
    55 | 63 | 66 |
    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 |
    19 | }> 20 | 21 | 22 |
    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 { 20 | return await this.likeModel.updateOne({ userId }, { $set: { likedIds } }); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /server/src/main.ts: -------------------------------------------------------------------------------- 1 | import * as session from 'express-session'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { ValidationPipe } from '@nestjs/common'; 4 | 5 | import { AppModule } from './app.module'; 6 | import { HttpExceptionFilter } from 'src/common/http-execption-filter'; 7 | 8 | const PORT = 3001; 9 | 10 | async function bootstrap() { 11 | const app = await NestFactory.create(AppModule); 12 | app.useGlobalFilters(new HttpExceptionFilter()); // 전역 필터 적용 13 | app.useGlobalPipes( 14 | new ValidationPipe({ 15 | whitelist: true, 16 | transform: true, 17 | }), 18 | ); 19 | app.use( 20 | session({ 21 | secret: 'users', 22 | resave: false, 23 | saveUninitialized: false, 24 | }), 25 | ); 26 | app.enableCors({ 27 | origin: [process.env.CLIENT_URL], 28 | methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS', 29 | credentials: true, 30 | }); 31 | await app.listen(PORT); 32 | } 33 | bootstrap(); 34 | -------------------------------------------------------------------------------- /server/src/message/dto/send-message.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsHexadecimal, IsString, Length, MaxLength } from 'class-validator'; 2 | 3 | export class SendMessageRequest { 4 | @IsString() 5 | @IsHexadecimal() 6 | @Length(24, 24) 7 | to: string; 8 | 9 | @IsString() 10 | @MaxLength(140) 11 | content: string; 12 | } 13 | -------------------------------------------------------------------------------- /server/src/message/entities/content.entity.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema } from '@nestjs/mongoose'; 2 | import { IsString } from 'class-validator'; 3 | 4 | @Schema({ versionKey: false }) 5 | export class Content { 6 | @Prop({ required: true }) 7 | @IsString() 8 | from: string; 9 | 10 | @Prop({ required: true }) 11 | @IsString() 12 | content: string; 13 | 14 | @Prop({ required: true }) 15 | readonly createdAt: Date = new Date(); 16 | } 17 | -------------------------------------------------------------------------------- /server/src/message/entities/message.entity.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import { HydratedDocument } from 'mongoose'; 3 | import { IsString } from 'class-validator'; 4 | 5 | import { Content } from './content.entity'; 6 | import { BaseEntity } from 'src/common/base-entity'; 7 | 8 | export type MessageDocument = HydratedDocument; 9 | 10 | @Schema({ versionKey: false, timestamps: true }) 11 | export class Message extends BaseEntity { 12 | @Prop({ required: true }) 13 | @IsString() 14 | participants: string; 15 | 16 | @Prop({ required: true }) 17 | contents: Content[]; 18 | } 19 | 20 | export const messageSchema = SchemaFactory.createForClass(Message); 21 | -------------------------------------------------------------------------------- /server/src/message/message.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { UserService } from 'src/user/user.service'; 3 | import { MessageController } from './message.controller'; 4 | import { MessageService } from './message.service'; 5 | 6 | describe('MessageController', () => { 7 | const mockMessageService = {}; 8 | const mockUserService = {}; 9 | 10 | let controller: MessageController; 11 | 12 | beforeEach(async () => { 13 | const module: TestingModule = await Test.createTestingModule({ 14 | controllers: [MessageController], 15 | providers: [ 16 | { provide: MessageService, useValue: mockMessageService }, 17 | { provide: UserService, useValue: mockUserService }, 18 | ], 19 | }).compile(); 20 | 21 | controller = module.get(MessageController); 22 | }); 23 | 24 | it('should be defined', () => { 25 | expect(controller).toBeDefined(); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /server/src/message/message.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Get, 5 | Param, 6 | Post, 7 | Session, 8 | Sse, 9 | } from '@nestjs/common'; 10 | import { Observable } from 'rxjs'; 11 | 12 | import { SessionInfo } from 'src/common/d'; 13 | import { errors, SuccessResponse } from 'src/common/response'; 14 | import { UserService } from 'src/user/user.service'; 15 | import { SendMessageRequest } from './dto/send-message.dto'; 16 | import { MessageService } from './message.service'; 17 | 18 | @Controller('/api/message') 19 | export class MessageController { 20 | constructor( 21 | private readonly messageService: MessageService, 22 | private readonly userService: UserService, 23 | ) {} 24 | 25 | @Post('/send') 26 | async sendMessage( 27 | @Body() sendMessageRequest: SendMessageRequest, 28 | @Session() session: SessionInfo, 29 | ) { 30 | if (!session.userId) { 31 | throw errors.NOT_LOGGED_IN; 32 | } 33 | 34 | await this.messageService.updateContents( 35 | session.userId, 36 | sendMessageRequest, 37 | ); 38 | 39 | const { to, content } = { ...sendMessageRequest }; 40 | this.messageService.emit(to, { 41 | from: session.userId, 42 | content, 43 | createdAt: new Date().toString(), 44 | }); 45 | 46 | return new SuccessResponse(); 47 | } 48 | 49 | @Sse('event') 50 | sendEvent(@Session() session: SessionInfo): Observable { 51 | if (!session.userId) { 52 | throw errors.NOT_LOGGED_IN; 53 | } 54 | 55 | return this.messageService.subscribe(session.userId); 56 | } 57 | 58 | @Get('/:to') 59 | async findMessage(@Param('to') to: string, @Session() session: SessionInfo) { 60 | if (!session.userId) { 61 | throw errors.NOT_LOGGED_IN; 62 | } 63 | 64 | const message = await this.messageService.findMessageByParticipants( 65 | session.userId, 66 | to, 67 | ); 68 | 69 | const toUser = await this.userService.findUserById(to); 70 | 71 | return new SuccessResponse({ 72 | contents: message.contents, 73 | toUsername: toUser.username, 74 | }); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /server/src/message/message.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MongooseModule } from '@nestjs/mongoose'; 3 | 4 | import { MessageService } from './message.service'; 5 | import { MessageController } from './message.controller'; 6 | import { MessageRepository } from './message.repository'; 7 | import { Message, messageSchema } from './entities/message.entity'; 8 | import { UserModule } from 'src/user/user.module'; 9 | 10 | @Module({ 11 | imports: [ 12 | MongooseModule.forFeature([{ name: Message.name, schema: messageSchema }]), 13 | UserModule, 14 | ], 15 | controllers: [MessageController], 16 | providers: [MessageService, MessageRepository], 17 | }) 18 | export class MessageModule {} 19 | -------------------------------------------------------------------------------- /server/src/message/message.repository.ts: -------------------------------------------------------------------------------- 1 | import { Model } from 'mongoose'; 2 | import { Injectable } from '@nestjs/common'; 3 | import { InjectModel } from '@nestjs/mongoose'; 4 | 5 | import { Message, MessageDocument } from './entities/message.entity'; 6 | import { Content } from './entities/content.entity'; 7 | 8 | @Injectable() 9 | export class MessageRepository { 10 | constructor( 11 | @InjectModel(Message.name) private messageModel: Model, 12 | ) {} 13 | 14 | async create(from: string, to: string): Promise { 15 | const participants = this.sortParticipants(from, to); 16 | 17 | return await this.messageModel.create({ participants, contents: [] }); 18 | } 19 | 20 | async findByParticipants(from: string, to: string): Promise { 21 | const participants = this.sortParticipants(from, to); 22 | 23 | return await this.messageModel 24 | .findOne() 25 | .where('participants') 26 | .equals(participants); 27 | } 28 | 29 | async updateByContents(from: string, to: string, contents: Content[]) { 30 | const participants = this.sortParticipants(from, to); 31 | 32 | return await this.messageModel.updateOne( 33 | { participants }, 34 | { $set: { contents } }, 35 | ); 36 | } 37 | 38 | sortParticipants(from: string, to: string): string { 39 | const sortedParticipants = [from, to].sort(); 40 | const participants = sortedParticipants[0] + ',' + sortedParticipants[1]; 41 | 42 | return participants; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /server/src/test/mongo.ts: -------------------------------------------------------------------------------- 1 | import { MongoMemoryServer } from 'mongodb-memory-server'; 2 | import { MongooseModule, MongooseModuleOptions } from '@nestjs/mongoose'; 3 | 4 | let mongod: MongoMemoryServer; 5 | 6 | export const rootMongooseTestModule = (options: MongooseModuleOptions = {}) => 7 | MongooseModule.forRootAsync({ 8 | useFactory: async () => { 9 | mongod = await MongoMemoryServer.create(); 10 | const mongoUri = mongod.getUri(); 11 | return { 12 | uri: mongoUri, 13 | ...options, 14 | }; 15 | }, 16 | }); 17 | 18 | export const closeInMongodConnection = async () => { 19 | if (mongod) await mongod.stop(); 20 | }; 21 | -------------------------------------------------------------------------------- /server/src/test/stub.ts: -------------------------------------------------------------------------------- 1 | import { User } from 'src/user/entities/user.entity'; 2 | import { Types } from 'mongoose'; 3 | import { Interest, Language, TechStack } from 'src/common/enum'; 4 | 5 | export const CREATE_USER: Record = { 6 | STUB1: { 7 | authProvider: 'google', 8 | authId: '12345', 9 | username: 'aaaaa', 10 | interest: Interest.FRONTEND, 11 | techStack: [TechStack.REACT, TechStack.RECOIL], 12 | code: 'aaa', // 유저 생성 시에는 code가 들어가지 않음 13 | messageInfos: [], 14 | _id: new Types.ObjectId(), 15 | createdAt: '', 16 | updatedAt: '', 17 | }, 18 | STUB2: { 19 | authProvider: 'github', 20 | authId: '11111', 21 | username: 'bbbbb', 22 | interest: Interest.BACKEND, 23 | techStack: [TechStack.JAVA, TechStack.C_CPP], 24 | messageInfos: [], 25 | _id: new Types.ObjectId(), 26 | createdAt: '', 27 | updatedAt: '', 28 | }, 29 | STUB3: { 30 | authProvider: 'google', 31 | authId: '11111', 32 | username: 'abcde', 33 | interest: Interest.IOS, 34 | techStack: [TechStack.SWIFT, TechStack.PYTHON], 35 | messageInfos: [], 36 | _id: new Types.ObjectId(), 37 | createdAt: '', 38 | updatedAt: '', 39 | }, 40 | }; 41 | 42 | export const FULL_USER: Record = { 43 | STUB1: { 44 | authProvider: 'google', 45 | authId: '1928298', 46 | email: 'full1@gmail.com', 47 | username: 'full1', 48 | interest: Interest.FRONTEND, 49 | techStack: [TechStack.REACT, TechStack.RECOIL, TechStack.C_CPP], 50 | code: 'console.log(full1)', 51 | language: Language.TYPESCRIPT, 52 | worktype: 'everyday1', 53 | worktime: '1 to 1', 54 | requirements: ['this is 1'], 55 | messageInfos: [], 56 | _id: new Types.ObjectId(), 57 | createdAt: '', 58 | updatedAt: '', 59 | }, 60 | STUB2: { 61 | authProvider: 'github', 62 | authId: '143213', 63 | email: 'full2@gmail.com', 64 | username: 'full2', 65 | interest: Interest.BACKEND, 66 | techStack: [TechStack.TYPESCRIPT, TechStack.FLUTTER, TechStack.C_CPP], 67 | code: 'console.log(full2)', 68 | language: Language.JAVASCRIPT, 69 | worktype: 'everyday2', 70 | worktime: '2 to 2', 71 | requirements: ['this is 2'], 72 | messageInfos: [], 73 | _id: new Types.ObjectId(), 74 | createdAt: '', 75 | updatedAt: '', 76 | }, 77 | }; 78 | -------------------------------------------------------------------------------- /server/src/user/dto/create-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { plainToInstance } from 'class-transformer'; 2 | import { 3 | ArrayMaxSize, 4 | IsArray, 5 | IsEnum, 6 | IsString, 7 | MaxLength, 8 | MinLength, 9 | } from 'class-validator'; 10 | 11 | import { AuthInfo } from 'src/common/d'; 12 | import { Interest, TechStack } from 'src/common/enum'; 13 | import { User } from 'src/user/entities/user.entity'; 14 | 15 | export class CreateUserRequest { 16 | @IsString() 17 | @MinLength(4) 18 | @MaxLength(15) 19 | username: string; 20 | 21 | @IsEnum(Interest) 22 | interest: Interest; 23 | 24 | @IsArray() 25 | @ArrayMaxSize(3) 26 | @IsEnum(TechStack, { each: true }) 27 | techStack: TechStack[]; 28 | 29 | toEntity(authInfo?: AuthInfo): User { 30 | return plainToInstance(User, { ...this, ...authInfo, messageInfos: [] }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /server/src/user/dto/page-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArrayMaxSize, 3 | IsArray, 4 | IsBoolean, 5 | IsEnum, 6 | IsNumber, 7 | IsString, 8 | } from 'class-validator'; 9 | import { PaginateResult } from 'mongoose'; 10 | 11 | import { User } from 'src/user/entities/user.entity'; 12 | import { TechStack, Interest, Language } from 'src/common/enum'; 13 | 14 | export class PageUserResponse { 15 | @IsNumber() 16 | totalPage: number; // 전체 페이지 수 17 | 18 | @IsNumber() 19 | currentPage: number; // 현재 페이지 번호 20 | 21 | @IsNumber() 22 | totalNumOfData: number; // 총 데이터 개수 23 | 24 | @IsArray() 25 | list: SimplaUserResponse[]; 26 | 27 | constructor( 28 | paginate: PaginateResult, 29 | likedIds: Array | undefined, 30 | liked: boolean | undefined, 31 | ) { 32 | this.totalPage = paginate.totalPages; 33 | this.currentPage = paginate.page; 34 | this.totalNumOfData = paginate.totalDocs; 35 | this.list = paginate.docs.map( 36 | (user) => new SimplaUserResponse(user, likedIds, liked), 37 | ); 38 | } 39 | } 40 | 41 | export class SimplaUserResponse { 42 | @IsString() 43 | id: string; 44 | 45 | @IsString() 46 | code: string; 47 | 48 | @IsEnum(Language) 49 | language: Language; 50 | 51 | @IsEnum(Interest) 52 | interest: Interest; 53 | 54 | @IsArray() 55 | @IsString({ each: true }) 56 | @IsEnum(TechStack, { each: true }) 57 | techStack: TechStack[]; 58 | 59 | @IsArray() 60 | @IsString({ each: true }) 61 | @ArrayMaxSize(2) 62 | requirements: string[]; 63 | 64 | @IsBoolean() 65 | liked: boolean; 66 | 67 | constructor( 68 | user: User, 69 | likedIds: Array | undefined, 70 | liked: boolean | undefined, 71 | ) { 72 | this.id = user._id.toString(); 73 | this.code = user.code; 74 | this.language = user.language; 75 | this.interest = user.interest; 76 | this.techStack = user.techStack; 77 | this.requirements = user.requirements; 78 | this.liked = this.isLiked(user._id.toString(), likedIds, liked); 79 | } 80 | 81 | private isLiked( 82 | userId: string, 83 | likedIds: Array | undefined, 84 | liked: boolean | undefined, 85 | ) { 86 | //로그인X 87 | if (likedIds === undefined) { 88 | return false; 89 | } 90 | //로그인O and liked 쿼리 존재 91 | if (liked !== undefined) { 92 | return liked; 93 | } 94 | //로그인O and liked 쿼리X 95 | return likedIds.includes(userId); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /server/src/user/dto/pagination.ts: -------------------------------------------------------------------------------- 1 | import { Interest, TechStack } from 'src/common/enum'; 2 | 3 | export class Condition { 4 | interest?: Interest; 5 | techStack?: object; 6 | _id?: object; 7 | 8 | constructor( 9 | interest?: Interest, 10 | techStack?: TechStack[], 11 | liked?: true, 12 | likes?: string[], 13 | ) { 14 | if (interest) this.interest = interest; 15 | if (techStack?.length > 0) this.techStack = { $all: techStack }; 16 | if (liked) { 17 | this._id = { $in: likes }; 18 | } 19 | } 20 | } 21 | 22 | export class Pageable { 23 | sort: object = { updatedAt: -1 }; // 최신순 24 | limit: number; 25 | page: number; 26 | 27 | constructor(limit: number, page: number, sort?: object) { 28 | this.limit = limit; 29 | this.page = page; 30 | if (sort) this.sort = sort; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /server/src/user/dto/update-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { plainToInstance } from 'class-transformer'; 2 | import { 3 | ArrayMaxSize, 4 | IsArray, 5 | IsEnum, 6 | IsString, 7 | MaxLength, 8 | MinLength, 9 | } from 'class-validator'; 10 | 11 | import { MessageWith, User } from 'src/user/entities/user.entity'; 12 | import { Interest, Language, TechStack } from 'src/common/enum'; 13 | import { SessionInfo } from 'src/common/d'; 14 | 15 | export class UpdateUserRequest { 16 | @IsString() 17 | @MinLength(4) 18 | @MaxLength(15) 19 | username: string; 20 | 21 | @IsString() 22 | @MaxLength(1000) 23 | code: string; 24 | 25 | @IsEnum(Language) 26 | language: Language; 27 | 28 | @IsEnum(Interest, { each: true }) 29 | interest: Interest; 30 | 31 | @IsArray() 32 | @IsEnum(TechStack, { each: true }) 33 | @ArrayMaxSize(3) 34 | techStack: TechStack[]; 35 | 36 | @IsString() 37 | @MaxLength(80) 38 | worktype: string; 39 | 40 | @IsString() 41 | @MaxLength(80) 42 | worktime: string; 43 | 44 | @IsArray() 45 | @IsString({ each: true }) 46 | @MaxLength(10, { each: true }) 47 | @ArrayMaxSize(2) 48 | requirements: string[]; 49 | 50 | toEntity(sessionInfo: SessionInfo, messages: MessageWith[]): User { 51 | return plainToInstance(User, { 52 | ...sessionInfo.authInfo, 53 | ...this, 54 | messages, 55 | _id: sessionInfo.userId, 56 | }); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /server/src/user/entities/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { IsArray, IsEmail } from 'class-validator'; 2 | import { HydratedDocument, Types } from 'mongoose'; 3 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 4 | import * as mongoosePaginate from 'mongoose-paginate-v2'; 5 | 6 | import { BaseEntity } from 'src/common/base-entity'; 7 | import { TechStack, Interest, Language } from 'src/common/enum'; 8 | 9 | export type UserDocument = HydratedDocument; 10 | 11 | @Schema({ 12 | versionKey: false, 13 | timestamps: true, 14 | }) 15 | export class User extends BaseEntity { 16 | @Prop({ 17 | required: true, 18 | }) 19 | authProvider: string; 20 | 21 | @Prop({ 22 | required: true, 23 | }) 24 | authId: string; 25 | 26 | @Prop({ 27 | required: true, 28 | unique: true, 29 | }) 30 | username: string; 31 | 32 | @Prop({ required: true }) 33 | @IsArray() 34 | messageInfos: MessageWith[]; 35 | 36 | @Prop() 37 | email?: string; 38 | 39 | @Prop() 40 | interest?: Interest; 41 | 42 | @Prop() 43 | techStack?: TechStack[]; 44 | 45 | @Prop() 46 | code?: string; 47 | 48 | @Prop() 49 | language?: Language; 50 | 51 | @Prop() 52 | worktype?: string; 53 | 54 | @Prop() 55 | worktime?: string; 56 | 57 | @Prop() 58 | requirements?: string[]; 59 | } 60 | 61 | export const userSchema = SchemaFactory.createForClass(User); 62 | userSchema.plugin(mongoosePaginate); 63 | 64 | @Schema({ versionKey: false }) 65 | export class MessageWith { 66 | @Prop({ required: true }) 67 | with: Types.ObjectId; 68 | 69 | @Prop({ required: true }) 70 | lastCheckTime: Date; 71 | 72 | username: string | null; 73 | } 74 | -------------------------------------------------------------------------------- /server/src/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { forwardRef, Module } from '@nestjs/common'; 2 | import { MongooseModule } from '@nestjs/mongoose'; 3 | 4 | import { UserService } from './user.service'; 5 | import { UserController } from './user.controller'; 6 | import { User, userSchema } from './entities/user.entity'; 7 | import { UserRepository } from './user.repository'; 8 | import { LikeModule } from './../like/like.module'; 9 | 10 | @Module({ 11 | imports: [ 12 | MongooseModule.forFeature([{ name: User.name, schema: userSchema }]), 13 | forwardRef(() => LikeModule), 14 | ], 15 | controllers: [UserController], 16 | providers: [UserService, UserRepository], 17 | exports: [UserService, UserRepository], 18 | }) 19 | export class UserModule {} 20 | -------------------------------------------------------------------------------- /server/src/user/user.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectModel } from '@nestjs/mongoose'; 3 | import { PaginateModel } from 'mongoose'; 4 | 5 | import { Pageable, Condition } from './dto/pagination'; 6 | import { MessageWith, User, UserDocument } from './entities/user.entity'; 7 | 8 | @Injectable() 9 | export class UserRepository { 10 | constructor( 11 | @InjectModel(User.name) private userModel: PaginateModel, 12 | ) {} 13 | 14 | async create(user: User): Promise { 15 | return await this.userModel.create(user); 16 | } 17 | 18 | //페이징 19 | async findAll(condition: Condition, pageable: Pageable) { 20 | return await this.userModel.paginate(condition, pageable); 21 | } 22 | 23 | async findByAuthProviderAndAuthId( 24 | authProvider: string, 25 | authId: string, 26 | ): Promise { 27 | return await this.userModel 28 | .findOne() 29 | .and([{ authProvider: authProvider }, { authId: authId }]) 30 | .exec(); 31 | } 32 | 33 | async findByUsername(username: string): Promise { 34 | return await this.userModel.findOne().where('username').equals(username); 35 | } 36 | 37 | async findById(id: string): Promise { 38 | return await this.userModel.findOne().where('_id').equals(id); 39 | } 40 | 41 | async deleteById(userId: string): Promise { 42 | return await this.userModel.deleteOne({ id: userId }); 43 | } 44 | 45 | async update(user: User): Promise { 46 | return this.userModel.updateOne({ _id: user._id }, user); 47 | } 48 | 49 | async updateMessageInfos(id: string, messageInfos: MessageWith[]) { 50 | return this.userModel.updateOne({ _id: id }, { $set: { messageInfos } }); 51 | } 52 | 53 | async findAllJoinUser(id: string) { 54 | return await this.userModel.aggregate([ 55 | { 56 | $lookup: { 57 | from: 'users', 58 | localField: 'messageInfos.with', 59 | foreignField: '_id', 60 | as: 'withInfos', 61 | }, 62 | }, 63 | ]); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /server/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /server/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /server/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | } 21 | } 22 | --------------------------------------------------------------------------------