├── .git-message.txt
├── .github
├── ISSUE_TEMPLATE
│ ├── bug-report.md
│ ├── feature-template.md
│ └── study-template.md
├── PULL_REQUEST_TEMPLATE.md
└── workflows
│ ├── deploy.yml
│ └── dev-deploy.yml
├── .gitignore
├── README.md
├── client
├── .env.example
├── .eslintrc.js
├── .gitignore
├── .prettierrc
├── README.md
├── craco.config.js
├── package-lock.json
├── package.json
├── public
│ ├── index.html
│ ├── logo.png
│ └── robots.txt
├── src
│ ├── App.tsx
│ ├── components
│ │ ├── alert-confirm
│ │ │ └── AlertConfirm.jsx
│ │ ├── category-section
│ │ │ ├── CategorySection.jsx
│ │ │ └── style.jsx
│ │ ├── common
│ │ │ ├── Loading.tsx
│ │ │ ├── MainSection.tsx
│ │ │ ├── MdParser.test.jsx
│ │ │ ├── MdParser.tsx
│ │ │ ├── SectionTitle.jsx
│ │ │ └── WikiContentsIndex.jsx
│ │ ├── header
│ │ │ ├── Header.tsx
│ │ │ └── header-components
│ │ │ │ ├── HeaderMenu.jsx
│ │ │ │ ├── HeaderSearchBar.jsx
│ │ │ │ ├── HeaderUser.jsx
│ │ │ │ └── Logo.jsx
│ │ ├── join-section
│ │ │ ├── JoinSection.tsx
│ │ │ └── join-section-components
│ │ │ │ ├── Agreement.tsx
│ │ │ │ ├── AgreementButton.jsx
│ │ │ │ ├── AgreementCheckbox.jsx
│ │ │ │ └── AgreementContent.tsx
│ │ ├── login-section
│ │ │ └── LoginSection.tsx
│ │ ├── main-door
│ │ │ ├── MainDoor.tsx
│ │ │ └── content.js
│ │ ├── make-section
│ │ │ ├── MakeSection.jsx
│ │ │ ├── UpdateSection.jsx
│ │ │ └── make-section-components
│ │ │ │ ├── ContentEditIcon.jsx
│ │ │ │ ├── ContentImgUploadBtn.jsx
│ │ │ │ ├── DocCard.jsx
│ │ │ │ ├── Editor.jsx
│ │ │ │ ├── EditorBox.jsx
│ │ │ │ ├── EditorWithPreview.jsx
│ │ │ │ ├── InputTitle.jsx
│ │ │ │ ├── MakePageRule.jsx
│ │ │ │ ├── Preview.jsx
│ │ │ │ ├── TitleGuide.jsx
│ │ │ │ ├── input-title-components
│ │ │ │ ├── BoostCampId.jsx
│ │ │ │ ├── Classification.jsx
│ │ │ │ ├── CreateBtn.jsx
│ │ │ │ ├── DocName.jsx
│ │ │ │ ├── Generation.jsx
│ │ │ │ └── style.js
│ │ │ │ └── ruleText.js
│ │ ├── rank-section
│ │ │ ├── RankSection.tsx
│ │ │ └── rank-components
│ │ │ │ ├── ContributionRank.jsx
│ │ │ │ ├── MbtiRank.jsx
│ │ │ │ ├── mbti-list-component
│ │ │ │ ├── MbtiCircle.jsx
│ │ │ │ └── MbtiList.jsx
│ │ │ │ └── style.ts
│ │ ├── search-section
│ │ │ ├── SearchSection.jsx
│ │ │ └── search-section-components
│ │ │ │ ├── ResultContent.jsx
│ │ │ │ ├── ResultFooter.jsx
│ │ │ │ ├── ResultSummary.jsx
│ │ │ │ └── ResultView.jsx
│ │ ├── select-modal
│ │ │ └── SelectModal.jsx
│ │ ├── side-section
│ │ │ ├── SideSection.jsx
│ │ │ └── side-section-components
│ │ │ │ ├── SectionItem.jsx
│ │ │ │ ├── SectionListGenerator.tsx
│ │ │ │ ├── banners
│ │ │ │ └── Banners.tsx
│ │ │ │ ├── recents
│ │ │ │ └── RecentItem.tsx
│ │ │ │ └── top-views
│ │ │ │ └── TopViewItem.tsx
│ │ ├── total-documents-section
│ │ │ ├── TotalDocumentsSection.jsx
│ │ │ └── style.tsx
│ │ └── wiki-section
│ │ │ ├── WikiSection.tsx
│ │ │ └── wiki-section-components
│ │ │ ├── WikiCard.test.jsx
│ │ │ ├── WikiCard.tsx
│ │ │ ├── WikiCategory.test.jsx
│ │ │ └── WikiCategory.tsx
│ ├── event-handler
│ │ └── select-handler.ts
│ ├── index.tsx
│ ├── logo.svg
│ ├── pages
│ │ ├── CategoryPage.tsx
│ │ ├── ErrorPage.jsx
│ │ ├── GithubCallbackPage.jsx
│ │ ├── JoinPage.tsx
│ │ ├── LoginPage.tsx
│ │ ├── MainPage.tsx
│ │ ├── MakePage.tsx
│ │ ├── RankPage.tsx
│ │ ├── SearchPage.tsx
│ │ ├── TotalDocumentsPage.tsx
│ │ ├── UpdatePage.jsx
│ │ ├── WikiPage.jsx
│ │ └── common
│ │ │ └── PageLayout.jsx
│ ├── react-app-env.d.ts
│ ├── reducer
│ │ ├── doc-data-reducer.ts
│ │ ├── select-toggle-reducer.ts
│ │ └── select-type-reducer.ts
│ ├── reportWebVitals.ts
│ ├── resource
│ │ ├── img
│ │ │ ├── bold-icon.svg
│ │ │ ├── close.svg
│ │ │ ├── drop.svg
│ │ │ ├── edit.png
│ │ │ ├── genDownBtn.svg
│ │ │ ├── genUpBtn.svg
│ │ │ ├── github-white.png
│ │ │ ├── github.png
│ │ │ ├── image-upload-icon.svg
│ │ │ ├── instagram.png
│ │ │ ├── italic-icon.svg
│ │ │ ├── logo.png
│ │ │ ├── logo2.png
│ │ │ ├── map.svg
│ │ │ ├── no-image.png
│ │ │ ├── rank-page.svg
│ │ │ ├── rank.svg
│ │ │ ├── recent.svg
│ │ │ ├── search.svg
│ │ │ ├── ssul-banner.gif
│ │ │ ├── text-line-icon.svg
│ │ │ ├── total-page.svg
│ │ │ ├── user.svg
│ │ │ ├── world-cup-banner.png
│ │ │ └── write-page.svg
│ │ └── message
│ │ │ ├── index.js
│ │ │ └── words.js
│ ├── services
│ │ └── image-upload.js
│ ├── setupTests.ts
│ ├── styles
│ │ ├── fonts
│ │ │ ├── noto-sans-kr-v21-latin_korean-100.eot
│ │ │ ├── noto-sans-kr-v21-latin_korean-100.svg
│ │ │ ├── noto-sans-kr-v21-latin_korean-100.woff
│ │ │ ├── noto-sans-kr-v21-latin_korean-100.woff2
│ │ │ ├── noto-sans-kr-v21-latin_korean-300.eot
│ │ │ ├── noto-sans-kr-v21-latin_korean-300.svg
│ │ │ ├── noto-sans-kr-v21-latin_korean-300.woff
│ │ │ ├── noto-sans-kr-v21-latin_korean-300.woff2
│ │ │ ├── noto-sans-kr-v21-latin_korean-500.eot
│ │ │ ├── noto-sans-kr-v21-latin_korean-500.svg
│ │ │ ├── noto-sans-kr-v21-latin_korean-500.woff
│ │ │ ├── noto-sans-kr-v21-latin_korean-500.woff2
│ │ │ ├── noto-sans-kr-v21-latin_korean-700.eot
│ │ │ ├── noto-sans-kr-v21-latin_korean-700.svg
│ │ │ ├── noto-sans-kr-v21-latin_korean-700.woff
│ │ │ ├── noto-sans-kr-v21-latin_korean-700.woff2
│ │ │ ├── noto-sans-kr-v21-latin_korean-900.eot
│ │ │ ├── noto-sans-kr-v21-latin_korean-900.svg
│ │ │ ├── noto-sans-kr-v21-latin_korean-900.woff
│ │ │ ├── noto-sans-kr-v21-latin_korean-900.woff2
│ │ │ ├── noto-sans-kr-v21-latin_korean-regular.eot
│ │ │ ├── noto-sans-kr-v21-latin_korean-regular.svg
│ │ │ ├── noto-sans-kr-v21-latin_korean-regular.woff
│ │ │ └── noto-sans-kr-v21-latin_korean-regular.woff2
│ │ ├── scss
│ │ │ ├── CallbackPage.module.scss
│ │ │ ├── ErrorPage.module.scss
│ │ │ ├── Page.module.scss
│ │ │ ├── global.scss
│ │ │ └── index.scss
│ │ └── styled-components
│ │ │ └── mixin.ts
│ ├── types
│ │ └── api-document.ts
│ └── utils
│ │ ├── display-width.ts
│ │ ├── documents.js
│ │ ├── index.js
│ │ ├── ip-check.js
│ │ ├── login.js
│ │ └── validator.js
├── tsconfig.json
└── tsconfig.path.json
├── docker
├── docker-compose.dev.yml
├── docker-compose.prod.yml
├── docker-compose.yml
├── nginx-dev
│ ├── conf.d
│ │ └── default.conf
│ ├── fastcgi_params
│ ├── mime.types
│ ├── nginx.conf
│ ├── scgi_params
│ └── uwsgi_params
├── nginx-prod
│ ├── conf.d
│ │ └── default.conf
│ ├── fastcgi_params
│ ├── mime.types
│ ├── nginx.conf
│ ├── scgi_params
│ └── uwsgi_params
└── nginx
│ └── nginx
│ ├── conf.d
│ ├── default.conf
│ └── default.dev.conf
│ ├── fastcgi_params
│ ├── mime.types
│ ├── nginx.conf
│ ├── scgi_params
│ └── uwsgi_params
├── package-lock.json
└── server
├── .env.example
├── .eslintrc.js
├── .prettierrc
├── package-lock.json
├── package.json
├── src
├── api
│ ├── auth.ts
│ ├── categories.ts
│ ├── documents.test.ts
│ ├── documents.ts
│ ├── images.ts
│ ├── index.ts
│ ├── middleware.ts
│ └── rank.ts
├── app.ts
├── config
│ └── index.ts
├── jobs
│ └── crontab.sh
├── loaders
│ ├── express.ts
│ ├── index.ts
│ └── mysql.ts
├── services
│ ├── db-pool.ts
│ ├── index.ts
│ ├── login.ts
│ ├── util.ts
│ └── words.ts
├── sql
│ ├── classification-query.ts
│ ├── documents-query.ts
│ ├── index.ts
│ ├── rank-query.ts
│ └── users-query.ts
├── subscribers
│ ├── document-subscriber.ts
│ └── index.ts
└── types
│ ├── apiInterface.ts
│ └── d.ts
└── tsconfig.json
/.git-message.txt:
--------------------------------------------------------------------------------
1 | # <타입>: <제목>
2 |
3 | ##### 제목은 최대 50 글자까지만 입력 ############## -> |
4 |
5 | # 본문은 아래 작성
6 |
7 | ######## 본문은 한 줄에 최대 72 글자까지만 입력 ########################### -> |
8 | # --- COMMIT END ---
9 | # <타입> 리스트
10 | # feat : 기능 (새로운 기능)
11 | # fix : 버그 (버그 수정)
12 | # refactor: 리팩토링
13 | # style : 스타일 (코드 형식, 세미콜론 추가: 비즈니스 로직에 변경 없음)
14 | # docs : 문서 (문서 추가, 수정, 삭제)
15 | # test : 테스트 (테스트 코드 추가, 수정, 삭제: 비즈니스 로직에 변경 없음)
16 | # chore : 기타 변경사항 (빌드 스크립트 수정 등)
17 | # perf : 코드 성능 개선에 대한 커밋
18 | # build : 빌드 관련 파일 수정에 대한 커밋
19 | # ------------------
20 | # 제목은 명령문으로
21 | # 제목 끝에 마침표(.) 금지
22 | # 제목과 본문을 한 줄 띄워 분리하기
23 | # 본문은 "어떻게" 보다 "무엇을", "왜"를 설명한다.
24 | # 본문에 여러줄의 메시지를 작성할 땐 "-"로 구분
25 | # ------------------
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug-report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: "[BUG] 오류 내역"
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | ## 스크린샷
11 |
12 |
13 | ## 버그에 대한 설명
14 |
15 | ## 버그가 발생하는 시나리오
16 | 1.
17 | 2.
18 | 3.
19 | 4.
20 | 5.
21 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature-template.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature template
3 | about: 기능 요구사항에 따른 이슈 템플릿
4 | title: "[FE/BE/설정 등] 기능"
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | ---
11 | name: Feature request
12 | about: 기능 관련 이슈 템플릿
13 | title: "[FE/BE/설정 등] 기능"
14 | labels: ''
15 | assignees: ''
16 |
17 | ---
18 |
19 | ## 설명
20 |
21 |
22 | ## 작업 내용
23 | - [ ]
24 | - [ ]
25 | - [x]
26 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/study-template.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Study template
3 | about: Suggest an idea for this project
4 | title: "[학습] 학습 계획 목록"
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | ## 설명
11 |
12 |
13 | ## 학습 내용
14 |
15 | - [x]
16 | - [ ]
17 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | ## 작업 내역
2 |
3 |
4 | ## 특이 사항
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | name: production deploy
2 |
3 | on:
4 | push:
5 | branches: [main]
6 |
7 | jobs:
8 | deploy:
9 | runs-on: ubuntu-latest
10 | env:
11 | PROJECT_FOLDER: boocam_wiki
12 |
13 | steps:
14 | - name: ssh connect & production
15 | uses: appleboy/ssh-action@master
16 | with:
17 | host: ${{ secrets.SERVER_HOST }}
18 | username: ${{ secrets.SERVER_USERNAME }}
19 | key: ${{ secrets.SERVER_PASSWORD }}
20 | port: ${{ secrets.SERVER_PORT }}
21 | envs: PROJECT_FOLDER
22 | script: |
23 | cd $PROJECT_FOLDER
24 | git fetch
25 | git pull origin
26 | cd client
27 | npm i
28 | npm run build
29 | cd ../server
30 | npm i
31 | npm run build
32 | cd ../docker
33 | mv -f nginx-prod/nginx nginx/nginx
34 | docker exec docker_pm2_1 pm2 reload /dist/app.js
35 | docker exec docker_nginx_1 service nginx reload
36 |
--------------------------------------------------------------------------------
/.github/workflows/dev-deploy.yml:
--------------------------------------------------------------------------------
1 | name: development deploy
2 |
3 | on:
4 | push:
5 | branches: [develop]
6 |
7 | jobs:
8 | deploy:
9 | runs-on: ubuntu-latest
10 | env:
11 | PROJECT_FOLDER: boocam_wiki-dev
12 |
13 | steps:
14 | - name: ssh connect & production
15 | uses: appleboy/ssh-action@master
16 | with:
17 | host: ${{ secrets.DEV_SERVER_HOST }}
18 | username: ${{ secrets.DEV_SERVER_USERNAME }}
19 | key: ${{ secrets.DEV_SERVER_PASSWORD }}
20 | port: ${{ secrets.DEV_SERVER_PORT }}
21 | envs: PROJECT_FOLDER
22 | script: |
23 | cd $PROJECT_FOLDER
24 | git fetch
25 | git pull origin
26 | cd client
27 | npm i
28 | npm run build
29 | cd ../server
30 | npm i
31 | npm run build
32 | cd ../docker
33 | cp -rf nginx-dev nginx/nginx
34 | docker exec dev-boocam-pm2 pm2 reload /dist/app.js
35 | docker exec dev-boocam-nginx service nginx reload
36 |
--------------------------------------------------------------------------------
/client/.env.example:
--------------------------------------------------------------------------------
1 | REACT_APP_GITHUB_CLIENT_ID=
2 | REACT_APP_GITHUB_CALLBACK_URL=
--------------------------------------------------------------------------------
/client/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | parser: '@typescript-eslint/parser',
3 | plugins: ['@typescript-eslint', 'prettier'],
4 | extends: [
5 | 'airbnb',
6 | 'plugin:import/errors',
7 | 'plugin:import/warnings',
8 | 'plugin:prettier/recommended',
9 | 'plugin:@typescript-eslint/recommended',
10 | ],
11 | rules: {
12 | 'linebreak-style': 0,
13 | 'import/prefer-default-export': 0,
14 | 'prettier/prettier': 0,
15 | 'import/extensions': 0,
16 | 'no-use-before-define': 0,
17 | 'import/no-unresolved': 0,
18 | 'import/no-extraneous-dependencies': 0, // 테스트 또는 개발환경을 구성하는 파일에서는 devDependency 사용을 허용
19 | 'no-shadow': 0,
20 | 'react/prop-types': 0,
21 | 'react/require-default-props': 'off',
22 | 'react/jsx-filename-extension': [2, { extensions: ['.js', '.jsx', '.ts', '.tsx'] }],
23 | 'jsx-a11y/no-noninteractive-element-interactions': 0,
24 | '@typescript-eslint/camelcase': 'off',
25 | camelcase: 'off',
26 | },
27 | env: {
28 | browser: true,
29 | node: true,
30 | jest: true,
31 | },
32 | };
33 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/client/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "semi": true,
4 | "useTabs": false,
5 | "tabWidth": 2,
6 | "trailingComma": "all",
7 | "printWidth": 120,
8 | "arrowParens": "always"
9 | }
--------------------------------------------------------------------------------
/client/README.md:
--------------------------------------------------------------------------------
1 | # Getting Started with Create React App
2 |
3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
4 |
5 | ## Available Scripts
6 |
7 | In the project directory, you can run:
8 |
9 | ### `npm start`
10 |
11 | Runs the app in the development mode.\
12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
13 |
14 | The page will reload if you make edits.\
15 | You will also see any lint errors in the console.
16 |
17 | ### `npm test`
18 |
19 | Launches the test runner in the interactive watch mode.\
20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
21 |
22 | ### `npm run build`
23 |
24 | Builds the app for production to the `build` folder.\
25 | It correctly bundles React in production mode and optimizes the build for the best performance.
26 |
27 | The build is minified and the filenames include the hashes.\
28 | Your app is ready to be deployed!
29 |
30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
31 |
32 | ### `npm run eject`
33 |
34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!**
35 |
36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
37 |
38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
39 |
40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
41 |
42 | ## Learn More
43 |
44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
45 |
46 | To learn React, check out the [React documentation](https://reactjs.org/).
47 |
--------------------------------------------------------------------------------
/client/craco.config.js:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line @typescript-eslint/no-var-requires
2 | const CracoAlias = require('craco-alias');
3 |
4 | module.exports = {
5 | plugins: [
6 | {
7 | plugin: CracoAlias,
8 | options: {
9 | source: 'tsconfig',
10 | baseUrl: './src',
11 | tsConfigPath: './tsconfig.path.json',
12 | },
13 | },
14 | ],
15 | };
16 |
--------------------------------------------------------------------------------
/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "client",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@craco/craco": "^6.4.2",
7 | "@material-ui/core": "^4.12.3",
8 | "browser-image-compression": "^1.0.17",
9 | "craco-alias": "^3.0.1",
10 | "dotenv": "^10.0.0",
11 | "jsonwebtoken": "^8.5.1",
12 | "node-sass": "^6.0.1",
13 | "query-string": "^7.0.1",
14 | "react": "^17.0.2",
15 | "react-dom": "^17.0.2",
16 | "react-markdown": "^7.1.0",
17 | "react-router-dom": "^5.3.0",
18 | "react-scripts": "4.0.3",
19 | "rehype-indexes": "^0.0.20",
20 | "remark": "^14.0.1",
21 | "remark-gfm": "^3.0.0",
22 | "strip-markdown": "^5.0.0",
23 | "styled-components": "^5.3.3",
24 | "web-vitals": "^1.1.2"
25 | },
26 | "scripts": {
27 | "start": "craco start",
28 | "build": "craco --max_old_space_size=1024 build",
29 | "postbuild": "mkdir -p ../docker/nginx/build && cp -r build/* ../docker/nginx/build",
30 | "test": "craco test",
31 | "eject": "react-scripts eject"
32 | },
33 | "eslintConfig": {
34 | "extends": [
35 | "react-app",
36 | "react-app/jest"
37 | ]
38 | },
39 | "browserslist": {
40 | "production": [
41 | ">0.2%",
42 | "not dead",
43 | "not op_mini all"
44 | ],
45 | "development": [
46 | "last 1 chrome version",
47 | "last 1 firefox version",
48 | "last 1 safari version"
49 | ]
50 | },
51 | "devDependencies": {
52 | "@testing-library/jest-dom": "^5.14.1",
53 | "@testing-library/react": "^11.2.7",
54 | "@testing-library/user-event": "^12.8.3",
55 | "@types/jest": "^26.0.24",
56 | "@types/node": "^12.20.36",
57 | "@types/react": "^17.0.33",
58 | "@types/react-dom": "^17.0.10",
59 | "@types/react-router-dom": "^5.3.2",
60 | "@types/styled-components": "^5.1.15",
61 | "@typescript-eslint/eslint-plugin": "^5.3.1",
62 | "eslint": "^7.32.0",
63 | "eslint-config-airbnb": "^18.2.1",
64 | "eslint-config-prettier": "^8.3.0",
65 | "eslint-plugin-prettier": "^4.0.0",
66 | "jest-styled-components": "^7.0.8",
67 | "prettier": "^2.4.1",
68 | "typescript": "^4.4.4"
69 | },
70 | "proxy": "http://localhost:3001"
71 | }
72 |
--------------------------------------------------------------------------------
/client/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | 부캠위키
9 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/client/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/web03-boocamWiki/e64e0cfda8ee3da0f5704f116224645566a791e2/client/public/logo.png
--------------------------------------------------------------------------------
/client/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/client/src/components/alert-confirm/AlertConfirm.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDom from 'react-dom';
3 | import styled from 'styled-components';
4 | import close from '@resource/img/close.svg';
5 | import logo from '@resource/img/logo2.png';
6 | import { font, flexBox } from '@styles/styled-components/mixin';
7 |
8 | const AlertConfirm = ({ modalContent, isConfirm = false, setLastCheck }) => {
9 | const handleYes = (e) => {
10 | setLastCheck(true);
11 | };
12 |
13 | return ReactDom.createPortal(
14 | <>
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | {modalContent}
24 | {isConfirm && 예}
25 | {isConfirm && 아니요}
26 |
27 |
28 | >,
29 | document.getElementById('portal'),
30 | );
31 | };
32 |
33 | const ButtonCommon = styled.button`
34 | ${flexBox({ justifyContent: 'center', alignItems: 'center' })};
35 | ${font({ color: '#fff', size: '16px', weight: '500' })};
36 | width: 70px;
37 | height: 28px;
38 | border-radius: 5px;
39 | border: none;
40 | position: absolute;
41 | top: 127px;
42 |
43 | &:hover {
44 | cursor: pointer;
45 | }
46 | `;
47 |
48 | const ButtonYes = styled(ButtonCommon)`
49 | background-color: #0055fb;
50 | left: 98px;
51 | `;
52 |
53 | const ButtonNo = styled(ButtonCommon)`
54 | background-color: #f45452;
55 | right: 98px;
56 | `;
57 |
58 | const Modal = styled.div`
59 | position: fixed;
60 | top: 50%;
61 | left: 50%;
62 | width: 350px;
63 | height: 200px;
64 | transform: translate(-50%, -50%);
65 | background-color: #fff;
66 | border: 1px solid #d7d7d7;
67 | border-radius: 10px;
68 | `;
69 |
70 | const ModalBackground = styled.div`
71 | position: fixed;
72 | top: 0;
73 | left: 0;
74 | width: 100%;
75 | height: 100%;
76 | background-color: #222222;
77 | filter: opacity(20%);
78 | `;
79 |
80 | const ModalHeader = styled.div`
81 | ${flexBox({ justifyContent: 'space-between', alignItems: 'center' })};
82 | padding: 5px;
83 | `;
84 |
85 | const BtnClose = styled.img`
86 | width: 24px;
87 | height: 24px;
88 | &:hover {
89 | cursor: pointer;
90 | }
91 | `;
92 |
93 | const ModalBorder = styled.div`
94 | width: 100%;
95 | height: 1px;
96 | background-color: #d7d7d7;
97 | `;
98 |
99 | const ModalBody = styled.div`
100 | ${flexBox({ justifyContent: 'center', alignItems: 'center' })}
101 | ${font({ color: '#222222', size: '18px', weight: 'normal' })}
102 | padding: 10px 20px 20px 20px;
103 | height: 165px;
104 | position: relative;
105 | `;
106 |
107 | export default AlertConfirm;
108 |
--------------------------------------------------------------------------------
/client/src/components/category-section/CategorySection.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import queryString from 'query-string';
3 | import { useLocation } from 'react-router';
4 | import { docTitleGen } from '@utils/documents';
5 | import { WikiCategory } from '../wiki-section/wiki-section-components/WikiCategory';
6 | import { ListItem, DocumentLink, InnerTitle, Contents, TotalCount, CategoryCho } from './style';
7 | import MainSection from '../common/MainSection';
8 | import Loading from '../common/Loading';
9 | import ResultFooter from '../search-section/search-section-components/ResultFooter';
10 |
11 | const CategorySection = ({ category }) => {
12 | const [relatedDocuments, setDocuments] = useState({});
13 | const { search, pathname } = useLocation();
14 | const [loading, setLoading] = useState(true);
15 | const { offset } = queryString.parse(search);
16 | const step = 30;
17 |
18 | useEffect(async () => {
19 | const fetched = await fetch(`/api/categories/${category}?offset=${offset}`);
20 | const { result } = await fetched.json();
21 | setDocuments(result);
22 | setLoading(false);
23 | }, [category, offset]);
24 |
25 | const createDocumentLink = (document) => {
26 | const id = `${document.generation}_${document.boostcamp_id}_${document.name}`;
27 | return (
28 |
29 |
30 | {docTitleGen({ ...document, boostcampId: document.boostcamp_id })}
31 |
32 |
33 | );
34 | };
35 |
36 | return (
37 |
38 | {loading ? (
39 |
40 | ) : (
41 | <>
42 |
43 |
44 | {`"${category}"`}에 속하는 문서
45 |
46 | 전체 {relatedDocuments.count}개 문서
47 |
48 | {Object.entries(relatedDocuments.list).map(([cho, documents]) => {
49 | return (
50 |
51 | {cho}
52 |
53 | {documents.map(createDocumentLink)}
54 |
55 | );
56 | })}
57 |
58 |
59 | >
60 | )}
61 |
62 | );
63 | };
64 |
65 | export default CategorySection;
66 |
--------------------------------------------------------------------------------
/client/src/components/category-section/style.jsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { Link } from 'react-router-dom';
3 |
4 | export const DocumentLink = styled(Link)`
5 | color: #36a4f3;
6 | text-decoration: none;
7 | `;
8 | export const InnerTitle = styled.h1`
9 | font-size: 20px;
10 | margin-bottom: 5px;
11 | font-weight: 400;
12 | `;
13 |
14 | export const TotalCount = styled.h2`
15 | font-size: 16px;
16 | margin-top: 8px;
17 | color: #222;
18 | font-weight: 300;
19 | text-align: right;
20 | `;
21 |
22 | export const Contents = styled.section`
23 | margin: 20px;
24 | hr {
25 | width: 100%;
26 | height: 1px;
27 | display: block;
28 | background-color: #bbb;
29 | border: none;
30 | }
31 | `;
32 |
33 | export const CategoryCho = styled.h2`
34 | font-size: 20px;
35 | margin-bottom: 5px;
36 | font-weight: 400;
37 | margin-left: 6px;
38 | `;
39 |
40 | export const ListItem = styled.li`
41 | list-style-position: inside;
42 | margin: 5px 0px 5px 16px;
43 | `;
44 |
--------------------------------------------------------------------------------
/client/src/components/common/Loading.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import CircularProgress from '@material-ui/core/CircularProgress';
3 | import styled from 'styled-components';
4 |
5 | const Loading = (): JSX.Element => {
6 | return (
7 |
8 |
9 |
10 | );
11 | };
12 |
13 | const LoadingContainer = styled.div`
14 | width: 100%;
15 | height: 70vh;
16 | display: flex;
17 | justify-content: center;
18 | align-items: center;
19 | `;
20 |
21 | export default Loading;
22 |
--------------------------------------------------------------------------------
/client/src/components/common/MainSection.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { BREAK_POINT_TABLET } from '@utils/display-width';
4 | import MainHeader from './SectionTitle';
5 |
6 | const MainSection = ({
7 | title,
8 | children,
9 | documentMode,
10 | }: {
11 | title: string;
12 | children?: React.ReactNode;
13 | documentMode?: { [key: string]: string | number };
14 | }): JSX.Element => {
15 | return (
16 |
17 |
18 | {children}
19 |
20 | );
21 | };
22 |
23 | const Main = styled.div`
24 | width: 100%;
25 | max-width: 990px;
26 | min-height: 1000px;
27 | background: white;
28 | outline: 1px solid #d7d7d7;
29 | box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
30 | border-radius: 10px;
31 | padding: 0 0 40px 0;
32 | @media only screen and (max-width: ${BREAK_POINT_TABLET}px) {
33 | max-width: ${BREAK_POINT_TABLET}px;
34 | }
35 | `;
36 |
37 | export default MainSection;
38 |
--------------------------------------------------------------------------------
/client/src/components/common/MdParser.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { screen, render } from '@testing-library/react';
3 |
4 | function ReactMarkdown({ children }){
5 | const [ , ,originalTag, content] = children.match(/((-|#|##|\*|\*\*|~) ?)?([a-zA-Z]+)/);
6 | let tag = '';
7 | switch (originalTag){
8 | case "#":
9 | tag = 'h1';
10 | break;
11 | case "##":
12 | tag = 'h2';
13 | break;
14 | case "-":
15 | tag = 'li';
16 | break;
17 | case "*":
18 | tag = 'em';
19 | break;
20 | case "**":
21 | tag = 'strong';
22 | break;
23 | case "~":
24 | tag = 'del';
25 | break;
26 | default:
27 | tag = 'p';
28 | break;
29 | }
30 |
31 | if(!tag) return children;
32 | const result = React.createElement(tag, null, content)
33 | return result;
34 | }
35 |
36 | describe('React Markdown mock 테스트 ', () =>{
37 |
38 | it('h1 테스트', ()=>{
39 | const tmp = "# hihi";
40 | render({tmp})
41 | expect(screen.getByText('hihi')).toContainHTML('hihi
')
42 | })
43 |
44 | it('h2 테스트', ()=>{
45 | const tmp = "## hihi";
46 | render({tmp})
47 | expect(screen.getByText('hihi')).toContainHTML('hihi
')
48 | });
49 |
50 | it('li 테스트', ()=>{
51 | const tmp = "- hihi";
52 | render({tmp})
53 | expect(screen.getByText('hihi')).toContainHTML('hihi')
54 | });
55 |
56 | it('strong 테스트', ()=>{
57 | const tmp = "**hihi**";
58 | render({tmp})
59 | expect(screen.getByText('hihi')).toContainHTML('hihi')
60 | });
61 |
62 | it('italic 테스트', ()=>{
63 | const tmp = "*hihi*";
64 | render({tmp})
65 | expect(screen.getByText('hihi')).toContainHTML('hihi')
66 | });
67 |
68 | it('del 테스트', ()=>{
69 | const tmp = "~hihi~";
70 | render({tmp})
71 | expect(screen.getByText('hihi')).toContainHTML('hihi')
72 | });
73 |
74 | it('p 테스트', ()=>{
75 | const tmp = "hihi";
76 | render({tmp})
77 | expect(screen.getByText('hihi')).toContainHTML('hihi
')
78 | });
79 |
80 | })
--------------------------------------------------------------------------------
/client/src/components/common/MdParser.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactMarkdown from 'react-markdown';
3 | import remarkGfm from 'remark-gfm';
4 | import rehypeIndex from 'rehype-indexes';
5 | import styled from 'styled-components';
6 |
7 | const MdParser = ({ content, color = '#222' }: { content: string; color?: string }): JSX.Element => {
8 | const clickHandler = ({ target }: React.MouseEvent) => {
9 | const eventTarget = target as HTMLDivElement;
10 | const img = eventTarget.closest('img');
11 | if (img) {
12 | window.open(img.src);
13 | }
14 | };
15 | return (
16 |
17 |
18 | {content}
19 |
20 |
21 | );
22 | };
23 |
24 | const MdParserContainer = styled.div`
25 | height: 100%;
26 |
27 | * {
28 | color: ${(props) => props.color};
29 | }
30 |
31 | img {
32 | width: fit-content;
33 | height: fit-content;
34 | max-width: 100%;
35 | &:hover {
36 | cursor: pointer;
37 | }
38 | }
39 |
40 | & {
41 | padding: 5px 20px;
42 | li {
43 | margin: 5px 0;
44 | }
45 |
46 | h1,
47 | h2,
48 | h3,
49 | h4,
50 | h5,
51 | h6 {
52 | margin: 20px 0 10px 0;
53 | }
54 |
55 | h1,
56 | h2 {
57 | padding-bottom: 5px;
58 | border-bottom: 1px solid #d7d7d7;
59 | }
60 |
61 | ul,
62 | ol {
63 | margin-top: 10px;
64 | padding-inline-start: 30px;
65 | }
66 | a,
67 | a:visited,
68 | a:link,
69 | a:hover,
70 | a:visited {
71 | text-decoration: none;
72 | color: #0055fb;
73 | }
74 | }
75 | `;
76 |
77 | export default MdParser;
78 |
--------------------------------------------------------------------------------
/client/src/components/common/SectionTitle.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router-dom';
3 | import styled from 'styled-components';
4 | import editIcon from '@resource/img/edit.png';
5 | import { flexBox } from '@styles/styled-components/mixin';
6 |
7 | const MainHeader = ({ title, documentMode }) => {
8 | return (
9 |
10 | {title}
11 | {documentMode && (
12 |
13 |
14 |
15 |
16 |
17 | )}
18 |
19 | );
20 | };
21 |
22 | const HeaderBox = styled.div`
23 | background: #e5e5e5;
24 | border-radius: 10px 10px 0px 0px;
25 | width: 100%;
26 | height: 60px;
27 | position: relative;
28 | outline: 1px solid #d7d7d7;
29 | ${flexBox({ alignItems: 'center', justifyContent: 'space-between' })}
30 | `;
31 |
32 | const HeaderTitle = styled.div`
33 | margin-left: 20px;
34 | font-family: 'Noto Sans KR';
35 | font-style: normal;
36 | font-weight: 500;
37 | font-size: 28px;
38 | line-height: 76px;
39 | text-overflow: ellipsis;
40 | overflow: hidden;
41 | white-space: nowrap;
42 | `;
43 |
44 | const EditIcon = styled.img`
45 | width: 36px;
46 | height: 36px;
47 | `;
48 |
49 | const EditButton = styled.button`
50 | width: 36px;
51 | height: 36px;
52 | margin-right: 20px;
53 | margin-top: 5px;
54 | background: transparent;
55 | border: 0px;
56 | cursor: pointer;
57 | &: active {
58 | transform: scale(0.95);
59 | }
60 | `;
61 |
62 | export default MainHeader;
63 |
--------------------------------------------------------------------------------
/client/src/components/common/WikiContentsIndex.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import ReactMarkdown from 'react-markdown';
4 | import rehypeIndexes from 'rehype-indexes';
5 | import { font } from '@styles/styled-components/mixin';
6 |
7 | const WikiContentsIndex = ({ text, title }) => {
8 | return (
9 |
10 | {title}
11 |
12 | {text}
13 |
14 |
15 | );
16 | };
17 |
18 | const Index = styled.div`
19 | h1,
20 | h2,
21 | h3,
22 | h4,
23 | h5,
24 | h6 {
25 | ${font({ size: '16px', weight: '400' })};
26 | width: fit-content;
27 | display: block;
28 | margin: 0px;
29 | }
30 | h1 {
31 | padding-left: 0px;
32 | }
33 | h2 {
34 | padding-left: 20px;
35 | }
36 | h3 {
37 | padding-left: 40px;
38 | }
39 | h4 {
40 | padding-left: 60px;
41 | }
42 | h5 {
43 | padding-left: 60px;
44 | }
45 | h6 {
46 | padding-left: 60px;
47 | }
48 | a,
49 | a:visited,
50 | a:link,
51 | a:hover,
52 | a:visited {
53 | margin-right: 4px;
54 | text-decoration: none;
55 | color: #0055fb;
56 | }
57 | padding: 14px 20px;
58 | border: 2px solid #d7d7d7;
59 | width: 350px;
60 | height: fit-content;
61 | `;
62 |
63 | const Title = styled.div`
64 | ${font({ size: '20px', weight: '500' })};
65 | `;
66 | const Padd = styled.div`
67 | margin-top: 12px;
68 | width: 290px;
69 | white-space: normal;
70 | word-wrap: normal;
71 | `;
72 |
73 | export default WikiContentsIndex;
74 |
--------------------------------------------------------------------------------
/client/src/components/header/Header.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { BREAK_POINT_TABLET } from '@utils/display-width';
4 | import Logo from './header-components/Logo';
5 | import HeaderMenu from './header-components/HeaderMenu';
6 | import HeaderSearchBar from './header-components/HeaderSearchBar';
7 | import HeaderUser from './header-components/HeaderUser';
8 |
9 | const Header = (): JSX.Element => {
10 | return (
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | );
20 | };
21 |
22 | const HeaderBar = styled.div`
23 | width: 100%;
24 | height: 60px;
25 | background: #e8a20c;
26 | display: flex;
27 | justify-content: center;
28 | box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.3);
29 |
30 | @media only screen and (max-width: ${BREAK_POINT_TABLET}px) {
31 | height: 120px;
32 | }
33 | `;
34 |
35 | const HeaderContainer = styled.div`
36 | padding: 5px;
37 | position: relative;
38 | max-width: 1300px;
39 | width: 100%;
40 | `;
41 |
42 | export default Header;
43 |
--------------------------------------------------------------------------------
/client/src/components/header/header-components/HeaderMenu.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router-dom';
3 | import styled from 'styled-components';
4 | import writePage from '@resource/img/write-page.svg';
5 | import rankPage from '@resource/img/rank-page.svg';
6 | import totalPage from '@resource/img/total-page.svg';
7 | import { BREAK_POINT_MOBILE } from '@utils/display-width';
8 |
9 | const HeaderMenu = () => {
10 | return (
11 |
12 |
18 |
24 |
30 |
31 | );
32 | };
33 |
34 | const NavMenu = styled.div`
35 | position: absolute;
36 | left: 150px;
37 | width: 383px;
38 | height: 50px;
39 | display: flex;
40 | justify-content: space-between;
41 | align-items: center;
42 |
43 | @media only screen and (max-width: ${BREAK_POINT_MOBILE}px) {
44 | width: 129px;
45 | left: 145px;
46 | }
47 | `;
48 |
49 | const MenuText = styled.p`
50 | @media only screen and (max-width: ${BREAK_POINT_MOBILE}px) {
51 | display: none;
52 | }
53 | `;
54 |
55 | const MenuImg = styled.img`
56 | width: 28px;
57 | height: 28px;
58 | `;
59 |
60 | const Menu = styled.div`
61 | width: 113px;
62 | &:hover {
63 | cursor: pointer;
64 | box-shadow: 0px 4px white;
65 | }
66 |
67 | @media only screen and (max-width: 768px) {
68 | width: 100%;
69 | &:hover {
70 | cursor: pointer;
71 | box-shadow: none;
72 | }
73 | }
74 | `;
75 |
76 | const aTagStyle = {
77 | textDecoration: 'none',
78 | color: 'white',
79 | fontFamily: 'Noto Sans KR',
80 | fontWeight: 500,
81 | fontSize: '22px',
82 | display: 'flex',
83 | lineHeight: '35px',
84 | alignItems: 'center',
85 | justifyContent: 'space-between',
86 | };
87 |
88 | export default HeaderMenu;
89 |
--------------------------------------------------------------------------------
/client/src/components/header/header-components/HeaderUser.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useContext } from 'react';
2 | import styled from 'styled-components';
3 | import user from '@resource/img/user.svg';
4 | import SelectModal from '@select-modal/SelectModal';
5 | import { SelectTgContext } from '@src/App';
6 | import { getAccessTokenPayload, isValidated } from '@utils/login';
7 |
8 | const HeaderUser = () => {
9 | const { isUserInfoOn } = useContext(SelectTgContext);
10 | const accessTokenPayload = getAccessTokenPayload();
11 | return (
12 |
13 | {!isValidated() && (
14 | <>
15 |
21 |
22 | >
23 | )}
24 | {isValidated() && (
25 | <>
26 |
32 |
33 | >
34 | )}
35 |
36 | );
37 | };
38 |
39 | const UserBtn = styled.button`
40 | position: absolute;
41 | right: 10px;
42 | background: #e8a20c;
43 | border: none;
44 | &:hover {
45 | cursor: pointer;
46 | }
47 | `;
48 |
49 | const UserSVG = styled.img`
50 | width: 50px;
51 | height: 50px;
52 | `;
53 |
54 | const CircleUserSVG = styled.img`
55 | width: 50px;
56 | height: 50px;
57 | border-radius: 999px;
58 | `;
59 |
60 | export default HeaderUser;
61 |
--------------------------------------------------------------------------------
/client/src/components/header/header-components/Logo.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router-dom';
3 | import styled from 'styled-components';
4 | import logo from '@resource/img/logo2.png';
5 |
6 | const Logo = () => {
7 | return (
8 |
9 |
10 |
11 | );
12 | };
13 |
14 | const LogoBtn = styled.img`
15 | width: 115px;
16 | height: 60px;
17 | position: absolute;
18 | left: 10px;
19 | top: -2px;
20 | &:hover {
21 | cursor: pointer;
22 | }
23 | `;
24 |
25 | export default Logo;
26 |
--------------------------------------------------------------------------------
/client/src/components/join-section/JoinSection.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import MainSection from '../common/MainSection';
4 | import Agreement from './join-section-components/Agreement';
5 |
6 | const AgreementWrapper = styled.div`
7 | display: flex;
8 | flex-direction: column;
9 | align-items: center;
10 | justify-content: center;
11 | `;
12 |
13 | const JoinSection = (): JSX.Element => {
14 | return (
15 |
16 |
17 |
18 |
19 |
20 | );
21 | };
22 |
23 | export default JoinSection;
24 |
--------------------------------------------------------------------------------
/client/src/components/join-section/join-section-components/Agreement.tsx:
--------------------------------------------------------------------------------
1 | import React, { useRef } from 'react';
2 | import styled from 'styled-components';
3 | import { font, flexBox } from '@styles/styled-components/mixin';
4 | import AgreementButton from './AgreementButton';
5 | import AgreementCheckbox from './AgreementCheckbox';
6 | import AgreementContent from './AgreementContent';
7 |
8 | const AgreementWrapper = styled.div`
9 | position: relative;
10 | top: 80px;
11 | ${flexBox({ direction: 'column', justifyContent: 'center', alignItems: 'center' })};
12 | padding: 35px 0px;
13 | border: 1px solid #bbbbbb;
14 | border-radius: 5px;
15 | width: 70%;
16 | max-width: 525px;
17 | min-width: 355px;
18 |
19 | div {
20 | margin: 15px 0px;
21 | }
22 | `;
23 |
24 | const AgreementTitle = styled.div`
25 | ${font({ size: '28px', weight: 'bold', color: '#000000' })};
26 | `;
27 |
28 | const Agreement = (): JSX.Element => {
29 | const checkbox = useRef(null);
30 |
31 | return (
32 |
33 | 회원가입 안내
34 |
35 |
36 |
37 |
38 | );
39 | };
40 |
41 | export default Agreement;
42 |
--------------------------------------------------------------------------------
/client/src/components/join-section/join-section-components/AgreementButton.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useHistory } from 'react-router';
3 | import styled from 'styled-components';
4 | import { font, flexBox } from '@styles/styled-components/mixin';
5 | import { authFetch, setAccessToken, setRefreshToken } from '@utils/login';
6 |
7 | const ButtonWrapper = styled.div`
8 | ${flexBox({ direction: 'column', alignItems: 'center', justifyContent: 'center' })}
9 | `;
10 |
11 | const RegisterButton = styled.button`
12 | ${font({ color: '#ffffff', size: '18px', weight: 'bold' })}
13 | border: none;
14 | border-radius: 11px;
15 | background-color: #e8a20c;
16 | width: 150px;
17 | height: 40px;
18 | cursor: pointer;
19 | `;
20 |
21 | const BackButton = styled.p`
22 | ${font({ color: '#888888', size: '11px' })}
23 | text-decoration-line: underline;
24 | cursor: pointer;
25 | `;
26 |
27 | const AgreementButton = ({ _ref }) => {
28 | const history = useHistory();
29 | const toMain = () => {
30 | history.push('/');
31 | };
32 | const registSubmit = async (e) => {
33 | e.preventDefault();
34 | if (!_ref.current.checked) {
35 | alert('동의를 하셔야 회원가입을 하실 수 있습니다.');
36 | } else {
37 | try {
38 | const res = await authFetch('/api/auth/join', {
39 | method: 'POST',
40 | headers: { 'Content-Type': 'application/json' },
41 | });
42 | const {
43 | result: { accessToken, refreshToken },
44 | } = await res.json();
45 | if (res.status === 200) {
46 | setAccessToken(accessToken);
47 | setRefreshToken(refreshToken);
48 | alert('가입이 완료되었습니다.');
49 | toMain();
50 | }
51 | } catch (err) {
52 | console.error(err);
53 | }
54 | }
55 | };
56 |
57 | return (
58 |
59 |
60 |
61 | 회원가입
62 |
63 |
64 | 부스트캠프 멤버가 아닙니다
65 |
66 | );
67 | };
68 |
69 | export default AgreementButton;
70 |
--------------------------------------------------------------------------------
/client/src/components/join-section/join-section-components/AgreementCheckbox.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { font } from '@styles/styled-components/mixin';
4 |
5 | const CheckInput = styled.input`
6 | cursor: pointer;
7 | `;
8 |
9 | const CheckSpan = styled.span`
10 | ${font({ size: '16px', weight: '500' })}
11 | margin: 0px 10px;
12 | cursor: pointer;
13 | `;
14 |
15 | const AgreementCheckbox = ({ _ref }) => {
16 | const checkClick = (e) => {
17 | _ref.current.click();
18 | };
19 |
20 | return (
21 |
22 |
23 | 위 사항을 확인했습니다.
24 |
25 | );
26 | };
27 |
28 | export default AgreementCheckbox;
29 |
--------------------------------------------------------------------------------
/client/src/components/join-section/join-section-components/AgreementContent.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { font } from '@styles/styled-components/mixin';
4 |
5 | const AgreementContentWrapper = styled.div`
6 | text-align: center;
7 | ${font({ size: '16px', weight: '400', color: '#000000' })}
8 |
9 | b {
10 | ${font({ size: '18px', weight: '700', color: '#000000' })}
11 | }
12 | `;
13 |
14 | const AgreementContent = (): JSX.Element => {
15 | return (
16 |
17 |
18 |
해당 서비스는 부스트캠프 멤버를 위한 위키입니다.
19 |
20 | 멤버가 아닌 경우에는 가입하실 수 없습니다.
21 |
22 |
23 |
24 |
25 |
규정에 어긋나는 행위 또는 부캠 멤버가 아닌 경우,
26 |
관리자의 계정 제재조치가 있을 수 있습니다.
27 |
28 |
29 | );
30 | };
31 |
32 | export default AgreementContent;
33 |
--------------------------------------------------------------------------------
/client/src/components/login-section/LoginSection.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { useValidate } from '@utils/login';
4 | import { font, flexBox } from '@styles/styled-components/mixin';
5 | import githubWhiteIcon from '@resource/img/github-white.png';
6 | import MainSection from '../common/MainSection';
7 |
8 | const LoginSection = (): JSX.Element => {
9 | useValidate(false);
10 | return (
11 |
12 |
13 |
14 | 로그인
15 |
16 |
19 |
20 |
21 | Github로 계속하기
22 |
23 |
24 |
25 |
26 |
27 |
28 | );
29 | };
30 |
31 | const SectionWrapper = styled.div`
32 | ${flexBox({ direction: 'column', alignItems: 'center', justifyContent: 'center' })}
33 | position: relative;
34 | top: 80px;
35 | padding: 20px 20px;
36 | `;
37 |
38 | const LoginWrapper = styled.div`
39 | ${flexBox({ direction: 'column', alignItems: 'center', justifyContent: 'center' })}
40 | width: 100%;
41 | max-width: 525px;
42 | min-width: 355px;
43 | padding: 20px 40px;
44 | border: 1px solid #bbbbbb;
45 | border-radius: 10px;
46 | `;
47 |
48 | const LoginLabel = styled.p`
49 | ${font({ color: '#000000', size: '28px', weight: 'bold' })}
50 | margin-bottom: 40px;
51 | `;
52 |
53 | const ButtonWrapper = styled.div`
54 | padding: 20px 0px;
55 | width: 100%;
56 | `;
57 |
58 | const LoginButton = styled.button`
59 | ${font({ color: '#ffffff', size: '22px', weight: '400' })}
60 | ${flexBox({ justifyContent: 'center', alignItems: 'center' })}
61 | border-radius: 15px;
62 | width: 100%;
63 | height: 50px;
64 | background-color: #222222;
65 | position: relative;
66 |
67 | &:hover {
68 | cursor: pointer;
69 | }
70 |
71 | &:active {
72 | transform: scale(0.95);
73 | }
74 | `;
75 |
76 | const GithubIcon = styled.img`
77 | position: absolute;
78 | left: 10px;
79 | width: 32px;
80 | `;
81 |
82 | const Link = styled.a`
83 | text-decoration: none;
84 | `;
85 |
86 | export default LoginSection;
87 |
--------------------------------------------------------------------------------
/client/src/components/main-door/MainDoor.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import MainSection from '../common/MainSection';
3 | import MdParser from '../common/MdParser';
4 | import { content } from './content';
5 |
6 | const MainDoor = (): JSX.Element => {
7 | return (
8 |
9 |
10 |
11 | );
12 | };
13 |
14 | export default MainDoor;
15 |
--------------------------------------------------------------------------------
/client/src/components/main-door/content.js:
--------------------------------------------------------------------------------
1 | export const content = `# 공지
2 |
3 | 글을 작성하기 전 부스트캠프 규정을 반드시 숙지해주세요!
4 |
5 | # 환영합니다!
6 |
7 | 부스트캠프 멤버들이 가꾸어나가는 추억의 저장소입니다.
8 |
9 | 부스트캠프 멤버라면 누구나 기여할 수 있는 위키입니다.
10 |
11 | # 부스트캠프란?
12 |
13 | 부스트캠프란 개발자의 지속 가능한 성장을 위한 학습 커뮤니티입니다.
14 |
15 | 네이버 커넥트 재단에서 운영하고 있으며, 우리나라에서 시행 중인 최고의 부트캠프 중 하나입니다.
16 |
17 | 1기(2016) 40명, 2기(2017) 63명, 3기(2018) 48명, 4기(2019) 78명, 5기(2020) 169명의 수료생을 배출했으며 현재는 6기 과정이 진행 중입니다.
18 |
19 | 웹모바일 과정에서는 Web, ios, Android 과정으로 나누어 지며, 2021년부터 AI Tech 과정이 신설됐습니다.
20 |
21 | ## 공식사이트
22 |
23 | [https://boostcamp.connect.or.kr](https://boostcamp.connect.or.kr/)
24 |
25 | # 부캠위키 규정
26 |
27 | 1. **다음과 같은 내용은 작성을 금지합니다.**
28 | 1. 타인을 비방, 비난하는 내용
29 | 2. 욕설 및 음란성 내용
30 | 3. 부스트 캠프 미션 내용
31 | 4. 사적인 감정을 갖는 내용
32 | 5. 기타 오해의 소지가 있는 내용
33 |
34 | 위 내용에 대해 피해자의 요청이 있을 시, 관리자는 작성 로그를 제공할 수 있습니다.
35 |
36 | 2. **부스트캠프 관계자 외에 위키 내 항목에 대해서 편집, 혹은 추가를 할 수 없습니다.**
37 |
38 | 위반 시 영구 밴 조치가 이루어집니다.
39 |
40 | 3. **기타 부캠 위키와 관련 없는 글은 관리자가 삭제할 수 있습니다.**
41 | 4. **한 번 수정된 문서는 복구가 불가능 합니다. 신중하게 작성해주시기 바랍니다.**
42 | 1. 타인이 작성한 내용을 수정하지 않도록 주의하시기 바랍니다.
43 | 2. 실수로 문서를 초기화하신 경우, 관리자에게 연락 바랍니다.
44 |
45 | 위 사항을 어길 경우 무통보 삭제 혹은 내용 편집이 이루어집니다.
46 |
47 | 또한, 수위에 따라 계정 및 아이피 차단, 운영진에게 통보, 그에 따른 추가 조치가 일어날 수 있습니다.
48 |
49 | 부캠위키는 어떠한 책임도 지지 않으며, 소중한 동료들을 위해 규정 사항을 반드시 지켜주시기 바랍니다.
50 |
51 | ## 만든사람
52 |
53 | **소스코드**: https://github.com/boostcampwm-2021/web03-boocamWiki
54 |
55 | ### 6기
56 |
57 | - 김영수:
58 | - 김웅일:
59 | - 이광민:
60 | - 정요한:
61 | - 동료캠퍼들 : 동료들의 피드백이 없었다면, 완성하지 못했을 것 입니다.
62 | `;
63 |
--------------------------------------------------------------------------------
/client/src/components/make-section/make-section-components/ContentEditIcon.jsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from 'react';
2 | import styled from 'styled-components';
3 | import boldIcon from '@resource/img/bold-icon.svg';
4 | import italicIcon from '@resource/img/italic-icon.svg';
5 | import textLineIcon from '@resource/img/text-line-icon.svg';
6 |
7 | const addDecoration = {
8 | bold: '**',
9 | italic: '*',
10 | strikeout: '~',
11 | };
12 |
13 | const isDecoInSelectRange = (content, decoration, prevStart, prevEnd, nextStart, nextEnd) => {
14 | const prevText = content.substring(prevStart, prevEnd);
15 | const nextText = content.substring(nextStart, nextEnd);
16 | if (prevText === nextText && prevText === decoration) {
17 | return true;
18 | }
19 | return false;
20 | };
21 |
22 | const makeNewContent = (content, decoration, start, end) => {
23 | const decoLen = decoration.length;
24 | let newSelectText = decoration + content.substring(start, end) + decoration;
25 | let totalContent = content.substring(0, start) + newSelectText + content.substring(end, content.lengt);
26 | if (isDecoInSelectRange(content, decoration, start, start + decoLen, end - decoLen, end)) {
27 | newSelectText = content.substring(start + decoLen, end - decoLen);
28 | totalContent = content.substring(0, start) + newSelectText + content.substring(end, content.lengt);
29 | } else if (isDecoInSelectRange(content, decoration, start - decoLen, start, end, end + decoLen)) {
30 | newSelectText = content.substring(start, end);
31 | totalContent =
32 | content.substring(0, start - decoLen) + newSelectText + content.substring(end + decoLen, content.lengt);
33 | }
34 | return totalContent;
35 | };
36 |
37 | const ContentEditIcon = ({ docData, docDispatch }) => {
38 | const editContentByBtn = useCallback(
39 | (e) => {
40 | const { selectionStart, selectionEnd } = document.querySelector('textarea');
41 | if (selectionStart !== selectionEnd) {
42 | const content = makeNewContent(docData.content, addDecoration[e.target.id], selectionStart, selectionEnd);
43 | docDispatch({
44 | type: 'INPUT_DOC_DATA',
45 | payload: {
46 | content,
47 | },
48 | });
49 | }
50 | },
51 | [docData.content],
52 | );
53 | return (
54 | <>
55 |
56 |
57 |
58 | >
59 | );
60 | };
61 |
62 | const EditorIcon = styled.img`
63 | width: 25px;
64 | height: 25px;
65 | &:hover {
66 | cursor: pointer;
67 | }
68 | `;
69 |
70 | export default ContentEditIcon;
71 |
--------------------------------------------------------------------------------
/client/src/components/make-section/make-section-components/ContentImgUploadBtn.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { font, flexBox } from '@styles/styled-components/mixin';
4 | import { fileUploadValidator } from '@utils/validator';
5 | import { getImgUrl, showErrorCode } from '@services/image-upload';
6 | import imageUploadIcon from '@resource/img/image-upload-icon.svg';
7 |
8 | const ContentImgUploadBtn = ({ docData, docDispatch }) => {
9 | const appendImageLink = (imgUrl, target) => {
10 | const { selectionStart, selectionEnd } = document.querySelector('textarea');
11 | if (selectionStart !== selectionEnd) return;
12 | const prevContent = !docData.content ? '' : docData.content;
13 |
14 | const markdownImg = `![${target.files[0].name}](${imgUrl})`;
15 | const content =
16 | prevContent.substring(0, selectionStart) +
17 | markdownImg +
18 | prevContent.substring(selectionStart, prevContent.length);
19 | docDispatch({
20 | type: 'INPUT_DOC_DATA',
21 | payload: {
22 | content,
23 | },
24 | });
25 | };
26 |
27 | const contentImgInput = async (e) => {
28 | if (e.target.files && e.target.files[0]) {
29 | const errorCode = fileUploadValidator(e.target.files);
30 | if (errorCode > 0) {
31 | showErrorCode(errorCode);
32 | return;
33 | }
34 | const url = await getImgUrl(e.target.files[0], 1);
35 | appendImageLink(url, e.target);
36 | }
37 | };
38 | return (
39 | <>
40 |
41 |
42 |
43 |
44 | >
45 | );
46 | };
47 |
48 | const UploadBtn = styled.input`
49 | display: none;
50 | `;
51 |
52 | const UploadIcon = styled.img`
53 | width: 25px;
54 | height: 25px;
55 | &:hover {
56 | cursor: pointer;
57 | }
58 | `;
59 |
60 | const UploadLabel = styled.label`
61 | ${flexBox({ justifyContent: 'center', alignItems: 'center' })};
62 | `;
63 |
64 | export default ContentImgUploadBtn;
65 |
--------------------------------------------------------------------------------
/client/src/components/make-section/make-section-components/Editor.jsx:
--------------------------------------------------------------------------------
1 | import React, { useRef } from 'react';
2 | import styled from 'styled-components';
3 | import { font } from '@styles/styled-components/mixin';
4 | import { fileUploadValidator } from '@utils/validator';
5 | import { sendToStorage, showErrorCode } from '@services/image-upload';
6 |
7 | const Editor = ({ docData, docDispatch, setIsBlock, withPreview = false }) => {
8 | const inputRef = useRef(null);
9 |
10 | const changeHandler = (e) => {
11 | setIsBlock(true);
12 | docDispatch({
13 | type: 'INPUT_DOC_DATA',
14 | payload: {
15 | content: e.target.value,
16 | },
17 | });
18 | };
19 |
20 | const fileDrops = (dataTransfer) => {
21 | return dataTransfer.files.length > 0;
22 | };
23 |
24 | const appendImageLink = (imgUrl, target) => {
25 | const { selectionStart, selectionEnd } = target;
26 | if (selectionStart !== selectionEnd) return;
27 | const prevContent = !docData.content ? '' : docData.content;
28 | const content =
29 | prevContent.substring(0, selectionStart) + imgUrl + prevContent.substring(selectionStart, prevContent.length);
30 |
31 | docDispatch({
32 | type: 'INPUT_DOC_DATA',
33 | payload: {
34 | content,
35 | },
36 | });
37 | };
38 |
39 | const dropHandler = async (e) => {
40 | e.stopPropagation();
41 |
42 | if (fileDrops(e.dataTransfer)) {
43 | e.preventDefault();
44 | const errorCode = fileUploadValidator(e.dataTransfer.files);
45 | if (errorCode > 0) {
46 | showErrorCode(errorCode);
47 | return;
48 | }
49 |
50 | const resultUrl = await sendToStorage(e.dataTransfer.files);
51 | const imgUrl = resultUrl.join('\n');
52 | appendImageLink(imgUrl, e.target);
53 | }
54 | };
55 |
56 | const borderRadius = (withPreview) => {
57 | return withPreview ? '10px 0 0 10px' : '10px';
58 | };
59 |
60 | return (
61 |
69 | );
70 | };
71 |
72 | const EditorBox = styled.textarea`
73 | ${font({ size: '16px', weight: '500' })};
74 | width: 100%;
75 | height: 432px;
76 | resize: none;
77 | background: #f6f6f6;
78 | border: 1px solid #d7d7d7;
79 | box-sizing: border-box;
80 | border-radius: ${(props) => props.borderRadius || '10px'};
81 | outline: none;
82 | padding: 10px;
83 | `;
84 |
85 | export default Editor;
86 |
--------------------------------------------------------------------------------
/client/src/components/make-section/make-section-components/EditorWithPreview.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { flexBox } from '@styles/styled-components/mixin';
4 | import MdParser from '../../common/MdParser';
5 | import Editor from './Editor';
6 |
7 | const EditorWithPreview = ({ docData, docDispatch, setIsBlock }) => {
8 | const withPreview = true;
9 |
10 | return (
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | );
20 | };
21 |
22 | const EditorWrap = styled.div`
23 | ${flexBox({})}
24 | `;
25 |
26 | const HalfEditor = styled.div`
27 | width: 50%;
28 | `;
29 |
30 | const Preview = styled.div`
31 | width: 50%;
32 | height: 432px;
33 | overflow: auto;
34 | border: 1px solid #d7d7d7;
35 | box-sizing: border-box;
36 | border-radius: 0px 10px 10px 0px;
37 | `;
38 |
39 | export default EditorWithPreview;
40 |
--------------------------------------------------------------------------------
/client/src/components/make-section/make-section-components/MakePageRule.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import MdParser from '../../common/MdParser';
4 | import ruleMd from './ruleText';
5 |
6 | const MakePageRule = () => {
7 | return (
8 |
9 |
10 |
11 | );
12 | };
13 |
14 | const RuleWrap = styled.div`
15 | border: 1px solid #888888;
16 | width: 100%;
17 | background-color: #f6f6f6;
18 | margin-top: 30px;
19 | padding-bottom: 20px;
20 | margin-bottom: 20px;
21 | `;
22 |
23 | export default MakePageRule;
24 |
--------------------------------------------------------------------------------
/client/src/components/make-section/make-section-components/Preview.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import MdParser from '../../common/MdParser';
4 |
5 | const Preview = ({ docData }) => {
6 | return (
7 |
8 |
9 |
10 | );
11 | };
12 |
13 | const PreviewWrap = styled.div`
14 | width: 100%;
15 | height: 432px;
16 | overflow: auto;
17 | border: 1px solid #d7d7d7;
18 | box-sizing: border-box;
19 | border-radius: 10px;
20 | `;
21 |
22 | export default Preview;
23 |
--------------------------------------------------------------------------------
/client/src/components/make-section/make-section-components/TitleGuide.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { flexBox } from '@styles/styled-components/mixin';
4 | import { BREAK_POINT_MOBILE } from '@utils/display-width';
5 | import MdParser from '../../common/MdParser';
6 |
7 | const GuideMd = `## 📃 제목 작성 가이드
8 | - 기수와 아이디는 **캠퍼만** 입력 가능합니다.
9 | - 아이디는 **알파벳**과 **숫자**만 입력 가능합니다. 입력이 안될 경우 한/영 을 눌러주세요.
10 | - 이름에는 **특수문자** 입력이 불가능합니다.
11 | - 아이디와 이름은 **20 글자**를 넘을 수 없습니다.`;
12 |
13 | const GuideSpecialMd = `**기수, 아이디, 이름은** 등록 후 **수정이 불가능**합니다. 오탈자가 없도록 주의해 주세요!`;
14 |
15 | const TitleGuide = () => {
16 | return (
17 |
18 |
19 |
20 |
21 | );
22 | };
23 |
24 | const TitleGuideContainer = styled.div`
25 | ${flexBox({ direction: 'column', justifyContent: 'center', alignItems: 'center' })};
26 | width: 635px;
27 | border: 1px solid #bbbbbb;
28 | margin-top: 20px;
29 | padding-bottom: 10px;
30 |
31 | @media only screen and (max-width: ${BREAK_POINT_MOBILE}px) {
32 | width: 350px;
33 | }
34 | `;
35 |
36 | export default TitleGuide;
37 |
--------------------------------------------------------------------------------
/client/src/components/make-section/make-section-components/input-title-components/BoostCampId.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { WordManager } from '@resource/message';
4 | import { TextInputWrap, Text, Input } from './style';
5 |
6 | const BoostCampId = ({ docData, id, changeData }) => {
7 | const checkIdValidation = (e) => {
8 | if (e.target.value.length > 20) {
9 | e.target.value = e.target.value.slice(0, -1);
10 | }
11 | e.target.value = e.target.value.replace(/[^0-9A-Za-z]/g, '');
12 | e.target.value = e.target.value.toUpperCase();
13 | };
14 |
15 | return (
16 |
17 | 아이디
18 | {docData.member_type === WordManager.CAMPER && (
19 |
20 | )}
21 | {docData.member_type !== WordManager.CAMPER && (
22 |
23 | )}
24 |
25 | );
26 | };
27 |
28 | const InputCamper = styled(Input)`
29 | &:focus::-webkit-input-placeholder {
30 | color: transparent;
31 | }
32 | `;
33 |
34 | export default BoostCampId;
35 |
--------------------------------------------------------------------------------
/client/src/components/make-section/make-section-components/input-title-components/Classification.jsx:
--------------------------------------------------------------------------------
1 | import React, { useContext } from 'react';
2 | import SelectModal from '@select-modal/SelectModal';
3 | import { SelectTgContext } from '@src/App';
4 | import genDownBtn from '@resource/img/genDownBtn.svg';
5 | import { WordManager } from '@resource/message';
6 | import { TextInputWrap, Text, GenWrap, TypeInput, GenBtn } from './style';
7 |
8 | const PEOPLE_TYPE = {
9 | 캠퍼: WordManager.CAMPER,
10 | 마스터: WordManager.MASTER,
11 | 운영진: WordManager.MANAGER,
12 | 멘토: WordManager.MENTOR,
13 | 리뷰어: WordManager.REVIEWER,
14 | };
15 |
16 | const Classification = ({ memberType }) => {
17 | const { isPeopleTypeOn } = useContext(SelectTgContext);
18 | return (
19 |
20 | 분류
21 |
22 |
23 |
29 |
30 |
38 |
39 |
40 |
41 | );
42 | };
43 |
44 | export default Classification;
45 |
--------------------------------------------------------------------------------
/client/src/components/make-section/make-section-components/input-title-components/CreateBtn.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { ValidationWrap, ValidationBtn, CanText } from './style';
3 |
4 | let canText = { text: '중복 확인해 주세요', color: '888888' };
5 |
6 | const CreateBtn = ({ titleCheckHandler, canMake }) => {
7 | switch (canMake) {
8 | case true:
9 | canText = { text: '생성 가능', color: '#222222' };
10 | break;
11 | case false:
12 | canText = { text: '이미 존재합니다', color: '#F45452' };
13 | break;
14 | default:
15 | canText = { text: '값을 입력 하세요', color: '#888888' };
16 | break;
17 | }
18 |
19 | return (
20 |
21 | 중복 확인
22 | {canText.text}
23 |
24 | );
25 | };
26 |
27 | export default CreateBtn;
28 |
--------------------------------------------------------------------------------
/client/src/components/make-section/make-section-components/input-title-components/DocName.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { TextInputWrap, Text, Input } from './style';
4 |
5 | const DocName = ({ name, changeData }) => {
6 | const checkNameValidation = (e) => {
7 | if (e.target.value.length > 20) {
8 | e.target.value = e.target.value.slice(0, -1);
9 | }
10 | e.target.value = e.target.value.replace(/[^0-9a-zA-Zㄱ-ㅎㅏ-ㅣ가-힣]/g, '');
11 | };
12 |
13 | return (
14 |
15 | 이름
16 |
24 |
25 | );
26 | };
27 |
28 | const InputName = styled(Input)`
29 | &:focus::-webkit-input-placeholder {
30 | color: transparent;
31 | }
32 | `;
33 |
34 | export default DocName;
35 |
--------------------------------------------------------------------------------
/client/src/components/make-section/make-section-components/input-title-components/Generation.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import genDownBtn from '@resource/img/genDownBtn.svg';
3 | import genUpBtn from '@resource/img/genUpBtn.svg';
4 | import { WordManager } from '@resource/message';
5 | import { TextInputWrap, Text, GenWrap, GenInput, GenBtnWrap, GenBtn } from './style';
6 |
7 | const Generation = ({ docData, changeData, genBtnHandler }) => {
8 | const { generation } = docData;
9 | return (
10 |
11 | 기수
12 |
13 | {docData.member_type === WordManager.CAMPER && (
14 | <>
15 |
22 |
23 |
24 |
25 |
26 | >
27 | )}
28 | {docData.member_type !== WordManager.CAMPER && }
29 |
30 |
31 | );
32 | };
33 |
34 | export default Generation;
35 |
--------------------------------------------------------------------------------
/client/src/components/make-section/make-section-components/input-title-components/style.js:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { flexBox } from '../../../../styles/styled-components/mixin';
3 | import { BREAK_POINT_MOBILE } from '../../../../utils/display-width';
4 |
5 | export const TextInputWrap = styled.div`
6 | ${flexBox({ direction: 'column', justifyContent: 'space-around' })};
7 | width: 100px;
8 | height: 44px;
9 | margin-left: 15px;
10 |
11 | @media only screen and (max-width: ${BREAK_POINT_MOBILE}px) {
12 | ${flexBox({ justifyContent: 'space-between', alignItems: 'center' })};
13 | width: 200px;
14 | height: 50px;
15 | }
16 | `;
17 |
18 | export const Text = styled.div`
19 | width: 100px;
20 | height: 17px;
21 | color: #e8a20c;
22 | `;
23 |
24 | export const CanText = styled.div`
25 | width: 94px;
26 | color: ${(props) => props.color};
27 | font-weight: normal;
28 | text-align: center;
29 | margin-top: 3px;
30 | `;
31 |
32 | export const Input = styled.input`
33 | width: 100px;
34 | height: 23px;
35 | border: none;
36 | background-color: #f6f6f6;
37 | outline: none;
38 | font-size: 16px;
39 | `;
40 |
41 | export const GenWrap = styled.div`
42 | ${flexBox({ justifyContent: 'space-between', alignItems: 'center' })};
43 | `;
44 |
45 | export const TypeInput = styled.input`
46 | width: 80px;
47 | height: 23px;
48 | border: none;
49 | background-color: #f6f6f6;
50 | outline: none;
51 | font-size: 16px;
52 | &:hover {
53 | cursor: pointer;
54 | }
55 | `;
56 |
57 | export const GenInput = styled.input`
58 | width: 80px;
59 | height: 23px;
60 | border: none;
61 | background-color: #f6f6f6;
62 | outline: none;
63 | font-size: 16px;
64 | `;
65 |
66 | export const GenBtnWrap = styled.div`
67 | ${flexBox({ direction: 'column', justifyContent: 'space-between' })};
68 | width: 12px;
69 | height: 18px;
70 | `;
71 |
72 | export const GenBtn = styled.img`
73 | width: 12px;
74 | height: 6px;
75 | &:hover {
76 | cursor: pointer;
77 | }
78 | `;
79 |
80 | export const ValidationWrap = styled.div`
81 | ${flexBox({ direction: 'column', justifyContent: 'center', alignItems: 'center' })};
82 | `;
83 |
84 | export const ValidationBtn = styled.button`
85 | width: 94px;
86 | height: 34px;
87 | background-color: #e8a20c;
88 | color: white;
89 | border: none;
90 | border-radius: 11px;
91 | font-size: 18px;
92 | margin: 3px 10px 0 15px;
93 | &:hover {
94 | cursor: pointer;
95 | }
96 | &:active {
97 | transform: scale(0.95);
98 | }
99 | `;
100 |
101 | export const PeopleTypeSelect = styled.select`
102 | width: 93px;
103 | height: 23px;
104 | font-size: 15px;
105 | font-weight: normal;
106 | border: none;
107 | background-color: #f6f6f6;
108 | color: #888888;
109 | outline: none;
110 | `;
111 |
--------------------------------------------------------------------------------
/client/src/components/make-section/make-section-components/ruleText.js:
--------------------------------------------------------------------------------
1 | const ruleMd = `
2 | # 부캠위키 규정
3 |
4 | 1. **다음과 같은 내용은 작성을 금지합니다.**
5 | 1. 타인을 비방, 비난하는 내용
6 | 2. 욕설 및 음란성 내용
7 | 3. 부스트 캠프 미션 내용
8 | 4. 사적인 감정을 갖는 내용
9 | 5. 기타 오해의 소지가 있는 내용
10 |
11 | 위 내용에 대해 피해자의 요청이 있을 시, 관리자는 작성 로그를 제공할 수 있습니다.
12 |
13 | 2. **부스트캠프 관계자 외에 위키 내 항목에 대해서 편집, 혹은 추가를 할 수 없습니다.**
14 |
15 | 위반 시 영구 밴 조치가 이루어집니다.
16 |
17 | 3. **기타 부캠 위키와 관련 없는 글은 관리자가 삭제할 수 있습니다.**
18 | 4. **한 번 수정된 문서는 복구가 불가능 합니다. 신중하게 작성해주시기 바랍니다.**
19 | 1. 타인이 작성한 내용을 수정하지 않도록 주의하시기 바랍니다.
20 | 2. 실수로 문서를 초기화하신 경우, 관리자에게 연락 바랍니다.
21 |
22 | 위 사항을 어길 경우 무통보 삭제 혹은 내용 편집이 이루어집니다.
23 |
24 | 또한, 수위에 따라 계정 및 아이피 차단, 운영진에게 통보, 그에 따른 추가 조치가 일어날 수 있습니다.
25 |
26 | 부캠위키는 어떠한 책임도 지지 않으며, 소중한 동료들을 위해 규정 사항을 반드시 지켜주시기 바랍니다.
27 | `;
28 |
29 | export default ruleMd;
--------------------------------------------------------------------------------
/client/src/components/rank-section/RankSection.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { flexBox } from '@styles/styled-components/mixin';
4 | import MainSection from '../common/MainSection';
5 | import MbtiRank from './rank-components/MbtiRank';
6 |
7 | const RankSection = (): JSX.Element => {
8 | return (
9 |
10 |
11 |
12 |
13 |
14 | );
15 | };
16 |
17 | const RankCotainer = styled.div`
18 | ${flexBox({ direction: 'column', justifyContent: 'center', alignItems: 'center' })};
19 | margin-top: 30px;
20 | `;
21 |
22 | export default RankSection;
23 |
--------------------------------------------------------------------------------
/client/src/components/rank-section/rank-components/ContributionRank.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import styled from 'styled-components';
3 | import { BREAK_POINT_MOBILE } from '@utils/display-width';
4 | import { font, flexBox } from '@styles/styled-components/mixin';
5 | import Loading from '../../common/Loading';
6 |
7 | const ContributionRank = () => {
8 | const [contributionCount, setContributionCount] = useState();
9 | const [loading, setLoading] = useState(true);
10 |
11 | useEffect(() => {
12 | const getContributionCount = async () => {
13 | const res = await fetch('/api/rank/contribution');
14 | if (res.status === 200) {
15 | const { result } = await res.json();
16 | console.log('result : ', result);
17 |
18 | setContributionCount(result);
19 | setLoading(false);
20 | }
21 | };
22 |
23 | getContributionCount();
24 | }, []);
25 |
26 | return (
27 |
28 | {loading && }
29 | {!loading && (
30 | <>
31 | 기여도 Top 10
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | >
44 | )}
45 |
46 | );
47 | };
48 |
49 | const GraphSVG = styled.svg`
50 | width: 100%;
51 | height: 418px;
52 | padding-top: 70px;
53 | `;
54 |
55 | const GraphLine = styled.line`
56 | stroke: #ecececf5;
57 | stroke-width: 2;
58 | `;
59 |
60 | const ContributionRankContainer = styled.div`
61 | width: 710px;
62 | height: 458px;
63 | border: 1px solid #bbbbbb;
64 | box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
65 | border-radius: 10px;
66 | margin-top: 40px;
67 | position: relative;
68 | padding: 20px;
69 | `;
70 |
71 | const GraphTitle = styled.p`
72 | ${font({ color: '#222222', size: '18px', weight: '600' })};
73 | position: absolute;
74 | top: 15px;
75 | left: 15px;
76 | `;
77 |
78 | export default ContributionRank;
79 |
--------------------------------------------------------------------------------
/client/src/components/rank-section/rank-components/mbti-list-component/MbtiCircle.jsx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/web03-boocamWiki/e64e0cfda8ee3da0f5704f116224645566a791e2/client/src/components/rank-section/rank-components/mbti-list-component/MbtiCircle.jsx
--------------------------------------------------------------------------------
/client/src/components/rank-section/rank-components/mbti-list-component/MbtiList.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { flexBox, font } from '@styles/styled-components/mixin';
4 |
5 | const MbtiList = ({ order, mbti, color, ratio, count }) => {
6 | return (
7 |
8 | {order}
9 | {mbti}
10 | {ratio}%
11 | {count}명
12 |
13 | );
14 | };
15 |
16 | const MbtiListWrapper = styled.div`
17 | ${flexBox({ justifyContent: 'space-between', alignItems: 'center' })};
18 | ${font({ color: '#222222', size: '16px', weight: '500' })};
19 | width: 286px;
20 | height: 59px;
21 | padding: 13px;
22 | border-bottom: 1px solid #bbbbbb;
23 | `;
24 |
25 | const OrderWrapper = styled.p`
26 | color: #888888;
27 | `;
28 |
29 | const MbtiWrapper = styled.p`
30 | ${flexBox({ justifyContent: 'center', alignItems: 'center' })};
31 | background-color: ${(props) => props.color || '#222222'};
32 | border-radius: 16.5px;
33 | color: #fff;
34 | width: 92px;
35 | height: 33px;
36 | `;
37 |
38 | const RatioWrapper = styled.p``;
39 | const CountWrapper = styled.p``;
40 |
41 | export default MbtiList;
42 |
--------------------------------------------------------------------------------
/client/src/components/rank-section/rank-components/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { font, flexBox } from '@styles/styled-components/mixin';
3 |
4 | interface MBITColor {
5 | [color: string]: string;
6 | }
7 |
8 | export const MBTIColor = {
9 | ENTJ: '#9C1F61',
10 | ENFJ: '#39B989',
11 | ESFJ: '#F35860',
12 | ESTJ: '#9C9636',
13 | ENTP: '#9CCC3C',
14 | ENFP: '#D07C58',
15 | ESFP: '#FACC39',
16 | ESTP: '#608CEB',
17 | INTP: '#8A8AD0',
18 | INFP: '#FE9214',
19 | ISFP: '#8D2735',
20 | ISTP: '#643271',
21 | INTJ: '#754D2A',
22 | INFJ: '#EC5B84',
23 | ISFJ: '#40C9F5',
24 | ISTJ: '#26A347',
25 | };
26 |
--------------------------------------------------------------------------------
/client/src/components/search-section/SearchSection.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import { useHistory, useLocation } from 'react-router-dom';
3 | import queryString from 'query-string';
4 | import MainSection from '../common/MainSection';
5 | import Loading from '../common/Loading';
6 | import ResultView from './search-section-components/ResultView';
7 |
8 | const SearchSection = () => {
9 | const [searchResult, setSearchResult] = useState({});
10 | const [searchResultCount, setSearchResultCount] = useState(0);
11 | const [loading, setLoading] = useState(true);
12 | const history = useHistory();
13 | const { search } = useLocation();
14 | const { generation, boostcamp_id, name, content, offset = 1 } = queryString.parse(search);
15 | const [searchType, searchValue] = Object.entries({ generation, boostcamp_id, name, content }).filter(
16 | ([, value]) => value !== undefined,
17 | )[0] ?? ['', ''];
18 | const [currentPage, setCurrentPage] = useState(0);
19 |
20 | useEffect(() => {
21 | const getResultList = async () => {
22 | const res = await fetch(
23 | `/api/documents/search?${searchType}=${encodeURIComponent(searchValue)}&offset=${offset - 1}`,
24 | );
25 | if (res.status !== 200 && res.msg === 'fail') {
26 | history.push('/error');
27 | }
28 | const rjson = await res.json();
29 | return { result: rjson.result, offset: rjson.offset };
30 | };
31 |
32 | const getResultCount = async () => {
33 | const res = await fetch(`/api/documents/count?${searchType}=${encodeURIComponent(searchValue)}`);
34 | if (res.status !== 200) {
35 | history.push('/error');
36 | }
37 | const { result } = await res.json();
38 | return result;
39 | };
40 |
41 | const getContent = async () => {
42 | setLoading(true);
43 | const resultList = await getResultList();
44 | setSearchResult(resultList.result);
45 | const off = parseInt(resultList.offset, 10);
46 | setCurrentPage(off + 1);
47 | const resultCount = await getResultCount();
48 | setSearchResultCount(resultCount);
49 |
50 | if (searchType !== 'content' && resultList.result.length === 1 && resultCount === 1) {
51 | const [{ generation, boostcamp_id: boostcampId, name }] = resultList.result;
52 | history.push(`/w/${generation}_${boostcampId}_${name}`);
53 | }
54 |
55 | setLoading(false);
56 | };
57 | getContent();
58 | }, [search]);
59 |
60 | return (
61 |
62 | {loading && }
63 | {!loading && (
64 |
71 | )}
72 |
73 | );
74 | };
75 | export default SearchSection;
76 |
--------------------------------------------------------------------------------
/client/src/components/search-section/search-section-components/ResultSummary.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router-dom';
3 | import styled from 'styled-components';
4 |
5 | const searchTypeMap = {
6 | generation: '기수',
7 | boostcamp_id: '캠퍼번호',
8 | name: '이름',
9 | content: '내용',
10 | };
11 |
12 | const ResultSummary = ({ type, value, resultCount }) => {
13 | return (
14 |
15 |
16 | {searchTypeMap[type]}: {`${`'${value}'`}`}에 대한 {resultCount}건
17 |
18 |
19 | 원하는 문서가 없으신가요?
20 | [문서 작성]
21 | 을 통하여 작성하실 수 있습니다.
22 |
23 |
24 | );
25 | };
26 |
27 | const HeaderDiv = styled.div`
28 | margin-bottom: 20px;
29 | `;
30 |
31 | const SummaryDiv = styled.div`
32 | margin-bottom: 10px;
33 | color: #222222;
34 | font-size: 25px;
35 | font-weight: 500;
36 | `;
37 |
38 | const StyledLink = styled(Link)`
39 | color: #0055fb;
40 | font-size: 16px;
41 | text-decoration: none;
42 | outline: none;
43 |
44 | :hover,
45 | :active {
46 | text-decoration: none;
47 | }
48 | `;
49 |
50 | const FooterSpan = styled.span`
51 | color: #222222;
52 | font-size: 16px;
53 | `;
54 |
55 | export default ResultSummary;
56 |
--------------------------------------------------------------------------------
/client/src/components/search-section/search-section-components/ResultView.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import ResultSummary from './ResultSummary';
4 | import ResultContent from './ResultContent';
5 | import ResultFooter from './ResultFooter';
6 |
7 | const ResultView = ({ type, value, result, resultCount, currentPage }) => {
8 | return (
9 |
10 |
11 |
12 |
13 |
14 | );
15 | };
16 |
17 | const ResultViewDiv = styled.div`
18 | padding: 30px 20px;
19 | `;
20 |
21 | export default ResultView;
22 |
--------------------------------------------------------------------------------
/client/src/components/select-modal/SelectModal.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 |
4 | const SelectModal = ({ className, content, isSelectOn, move }) => {
5 | const [first, last] = [0, content.length - 1];
6 |
7 | const checkFloor = (idx) => {
8 | if (idx === first && idx === last) {
9 | return '10px';
10 | }
11 | if (idx === first) {
12 | return '10px 10px 0 0';
13 | }
14 | if (idx === last) {
15 | return `0 0 10px 10px`;
16 | }
17 | return '0';
18 | };
19 |
20 | return (
21 | <>
22 | {isSelectOn && (
23 |
24 |
25 | {content.map((value, idx) => (
26 |
27 | {value}
28 |
29 | ))}
30 |
31 |
32 | )}
33 | >
34 | );
35 | };
36 |
37 | const SelectContainer = styled.div``;
38 |
39 | const SelectWrapper = styled.div`
40 | width: 165px;
41 | box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25), 0px 0px 4px rgba(204, 204, 204, 0.25);
42 | border-radius: 10px;
43 | position: absolute;
44 | top: ${(props) => props.move.top || '0px'};
45 | left: ${(props) => props.move.left || '0px'};
46 | transform: translateX(${(props) => props.move.translateX || '0'});
47 | z-index: 2;
48 | `;
49 |
50 | const SelectRow = styled.div`
51 | font-family: Noto Sans KR;
52 | font-weight: 500;
53 | font-size: 16px;
54 | width: 165px;
55 | height: 48px;
56 | padding: 16px;
57 | display: flex;
58 | align-items: center;
59 | justify-content: center;
60 | background: #f6f6f6;
61 | border: 1px solid #d7d7d7;
62 | border-radius: ${(props) => props.borderRadius || '0px'};
63 |
64 | &:hover {
65 | cursor: pointer;
66 | background: #e5e5e5;
67 | }
68 | `;
69 |
70 | const SelectBackground = styled.div`
71 | position: fixed;
72 | left: 0;
73 | top: 0;
74 | z-index: 1;
75 | width: 100%;
76 | height: 100%;
77 | background: transparent;
78 | cursor: default;
79 | `;
80 |
81 | export default SelectModal;
82 |
--------------------------------------------------------------------------------
/client/src/components/side-section/SideSection.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { BREAK_POINT_TABLET } from '@utils/display-width';
4 | import SectionItem from '@components/side-section/side-section-components/SectionItem';
5 | import { FetchingRecent, RecentItem } from '@components/side-section/side-section-components/recents/RecentItem';
6 | import { FetchingTopView, TopViewItem } from '@components/side-section/side-section-components/top-views/TopViewItem';
7 | import { Banners } from '@components/side-section/side-section-components/banners/Banners';
8 |
9 | const SideSection = () => {
10 | return (
11 |
12 |
13 |
14 |
15 |
16 | );
17 | };
18 |
19 | const SideArea = styled.div`
20 | display: flex;
21 | flex-direction: column;
22 | margin-left: 20px;
23 |
24 | @media only screen and (max-width: ${BREAK_POINT_TABLET}px) {
25 | display: none;
26 | }
27 | `;
28 |
29 | export default SideSection;
30 |
--------------------------------------------------------------------------------
/client/src/components/side-section/side-section-components/SectionItem.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { withRouter } from 'react-router';
3 | import styled from 'styled-components';
4 | import { SectionListGenerator } from './SectionListGenerator';
5 |
6 | const recordToItem = (item) => {
7 | const timetag = 'recent_created_at';
8 | const date = new Date(item[timetag]);
9 | return {
10 | name: item.name,
11 | generation: item.generation,
12 | boostcampId: item.boostcamp_id,
13 | timestamp: date,
14 | };
15 | };
16 |
17 | const SectionItem = ({ title, onLoadedFetch, itemTemplate, location }) => {
18 | const maxLength = 11;
19 | const [listItem, setListItem] = useState([]);
20 | useEffect(async () => {
21 | if (!onLoadedFetch) return;
22 | const response = await onLoadedFetch({ maxLength });
23 | const items = response.result.map(recordToItem);
24 | setListItem(items);
25 | }, [location.pathname]);
26 |
27 | return (
28 |
29 | {title}
30 |
31 |
32 | );
33 | };
34 |
35 | const Side = styled.div`
36 | width: 290px;
37 | height: 489px;
38 | background: white;
39 | margin-bottom: 20px;
40 | border: 1px solid #d7d7d7;
41 | box-sizing: border-box;
42 | box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
43 | border-radius: 10px;
44 | position: relative;
45 | `;
46 |
47 | const SideTitle = styled.p`
48 | height: 64px;
49 | font-size: 28px;
50 | padding-left: 10px;
51 | font-family: 'Noto Sans KR';
52 | font-weight: 500;
53 | display: flex;
54 | align-items: center;
55 | border-bottom: 2px solid #d7d7d7;
56 | `;
57 |
58 | export default withRouter(SectionItem);
59 |
--------------------------------------------------------------------------------
/client/src/components/side-section/side-section-components/SectionListGenerator.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { IDocument } from '@types-dir/api-document';
4 |
5 | export function SectionListGenerator({
6 | list,
7 | templateFunc,
8 | }: {
9 | list: T[];
10 | templateFunc: (arg: T) => JSX.Element;
11 | }): JSX.Element {
12 | return (
13 |
14 | {list.map((item) => {
15 | const id = item.name + item.boostcampId + item.generation;
16 | if (!templateFunc) return <>>;
17 | return - {templateFunc(item)}
;
18 | })}
19 |
20 | );
21 | }
22 |
23 | const Li = styled.li`
24 | list-style: none;
25 | height: 35px;
26 | border-bottom: 2px solid #d7d7d7;
27 | // display: flex;
28 | // align-items: center;
29 | // justify-content: space-between;
30 | // width: 280px;
31 | `;
32 |
--------------------------------------------------------------------------------
/client/src/components/side-section/side-section-components/banners/Banners.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { BREAK_POINT_TABLET } from '@utils/display-width';
4 | import worldcupBannerImg from '@resource/img/world-cup-banner.png';
5 | import ssulBannerGif from '@resource/img/ssul-banner.gif';
6 |
7 | export const Banners = (): JSX.Element => {
8 | const wiziWorldCupBannerLink = 'https://www.wiziboost.ga/';
9 | const ssulBannerLink = 'http://www.gaeinsa.kro.kr/';
10 | const title = '제휴 서비스';
11 | return (
12 |
13 | {title}
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | );
22 | };
23 |
24 | const BannerItem = styled.img`
25 | width: 288px;
26 | display: block;
27 | position: relative;
28 | @media only screen and (max-width: ${BREAK_POINT_TABLET}px) {
29 | display: none;
30 | }
31 | `;
32 |
33 | const Anchor = styled.a`
34 | display: inline-block;
35 | border-bottom: 2px solid #d7d7d7;
36 | box-sizing: border-box;
37 | `;
38 |
39 | const Side = styled.div`
40 | width: 290px;
41 | background: white;
42 | margin-bottom: 20px;
43 | border: 1px solid #d7d7d7;
44 | box-sizing: border-box;
45 | box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
46 | border-radius: 10px;
47 | position: relative;
48 | padding-bottom: 35px;
49 | `;
50 |
51 | const SideTitle = styled.p`
52 | height: 64px;
53 | font-size: 28px;
54 | padding-left: 10px;
55 | font-family: 'Noto Sans KR';
56 | font-weight: 500;
57 | display: flex;
58 | align-items: center;
59 | border-bottom: 2px solid #d7d7d7;
60 | `;
61 |
--------------------------------------------------------------------------------
/client/src/components/side-section/side-section-components/recents/RecentItem.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router-dom';
3 | import styled from 'styled-components';
4 | import { Utils } from '@utils/index';
5 | import { IDocument } from '@types-dir/api-document';
6 |
7 | const getTime = (arg: Date): string => {
8 | let hour = arg.getHours().toString();
9 | hour = `0${hour}`.slice(-2);
10 | let minute = arg.getMinutes().toString();
11 | minute = `0${minute}`.slice(-2);
12 | return `${hour}:${minute}`;
13 | };
14 |
15 | export const RecentItem = (arg: IDocument): JSX.Element => {
16 | const { name, boostcampId, generation, timestamp } = arg;
17 | return (
18 |
19 |
20 | {Utils.docTitleGen({ name, boostcampId, generation })}
21 | {getTime(timestamp)}
22 |
23 |
24 | );
25 | };
26 |
27 | export const FetchingRecent = async ({ maxLength }: { maxLength: number }): Promise => {
28 | const result = await fetch(`/api/documents/recents?count=${maxLength}`);
29 | const list = await result.json();
30 | return list;
31 | };
32 |
33 | const Flexed = styled.div`
34 | display: flex;
35 | justify-content: space-between;
36 | align-items: center;
37 | padding: 0px 8px;
38 | cursor: pointer;
39 | height: 100%;
40 | &:hover {
41 | background: #f7f7f7;
42 | }
43 | `;
44 |
45 | const TitleP = styled.p`
46 | color: #0055fb;
47 | overflow: hidden;
48 | white-space: nowrap;
49 | text-overflow: ellipsis;
50 | margin-right: 6px;
51 | `;
52 |
53 | const RightP = styled.p`
54 | color: #000;
55 | `;
56 |
57 | const StyledLink = styled(Link)`
58 | text-decoration: none;
59 | &:focus,
60 | &:hover,
61 | &:visited,
62 | &:link,
63 | &:active {
64 | text-decoration: none;
65 | }
66 | `;
67 |
--------------------------------------------------------------------------------
/client/src/components/side-section/side-section-components/top-views/TopViewItem.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router-dom';
3 | import styled from 'styled-components';
4 | import { Utils } from '@utils/index';
5 | import { IDocument } from '@types-dir/api-document';
6 |
7 | export const TopViewItem = (arg: IDocument): JSX.Element => {
8 | const { name, boostcampId, generation } = arg;
9 | return (
10 |
11 |
12 | {Utils.docTitleGen({ generation, boostcampId, name })}
13 |
14 |
15 | );
16 | };
17 |
18 | export const FetchingTopView = async ({ maxLength }: { maxLength: number }): Promise => {
19 | const result = await fetch(`/api/documents/ranks?count=${maxLength}`);
20 | const list = await result.json();
21 | return list;
22 | };
23 |
24 | const Flexed = styled.div`
25 | display: flex;
26 | justify-content: space-between;
27 | align-items: center;
28 | padding: 0px 8px;
29 | cursor: pointer;
30 | height: 100%;
31 | &:hover {
32 | background: #f7f7f7;
33 | }
34 | `;
35 |
36 | const TitleP = styled.p`
37 | color: #0055fb;
38 | overflow: hidden;
39 | white-space: nowrap;
40 | text-overflow: ellipsis;
41 | `;
42 |
43 | const StyledLink = styled(Link)`
44 | text-decoration: none;
45 | &:focus,
46 | &:hover,
47 | &:visited,
48 | &:link,
49 | &:active {
50 | text-decoration: none;
51 | }
52 | `;
53 |
--------------------------------------------------------------------------------
/client/src/components/total-documents-section/TotalDocumentsSection.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import queryString from 'query-string';
3 | import { useLocation } from 'react-router';
4 | import { docTitleGen } from '@utils/documents';
5 | import MainSection from '@components/common/MainSection';
6 | import Loading from '@components/common/Loading';
7 | import ResultFooter from '@components/search-section/search-section-components/ResultFooter';
8 | import { ListItem, DocumentLink, InnerTitle, Contents, TotalCount, CategoryCho } from './style';
9 |
10 | const TotalDocumentsSection = () => {
11 | const [totalDocuments, setDocuments] = useState({});
12 | const { search, pathname } = useLocation();
13 | const [loading, setLoading] = useState(true);
14 | const { offset } = queryString.parse(search);
15 | const step = 30;
16 |
17 | useEffect(async () => {
18 | const fetched = await fetch(`/api/documents/all?offset=${offset}`);
19 | const { result } = await fetched.json();
20 | setDocuments(result);
21 | setLoading(false);
22 | }, [search, pathname]);
23 |
24 | const createDocumentLink = (document) => {
25 | const id = `${document.generation}_${document.boostcamp_id}_${document.name}`;
26 | return (
27 |
28 |
29 | {docTitleGen({ ...document, boostcampId: document.boostcamp_id })}
30 |
31 |
32 | );
33 | };
34 |
35 | return (
36 |
37 | {loading ? (
38 |
39 | ) : (
40 | <>
41 |
42 | 부캠 위키에 있는 모든 문서
43 |
44 | 전체 {totalDocuments.count}개 문서
45 |
46 | {Object.entries(totalDocuments.list).map(([cho, documents]) => {
47 | return (
48 |
49 | {cho}
50 |
51 | {documents.map(createDocumentLink)}
52 |
53 | );
54 | })}
55 |
56 |
57 | >
58 | )}
59 |
60 | );
61 | };
62 |
63 | export default TotalDocumentsSection;
64 |
--------------------------------------------------------------------------------
/client/src/components/total-documents-section/style.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { Link } from 'react-router-dom';
3 |
4 | export const DocumentLink = styled(Link)`
5 | color: #36a4f3;
6 | text-decoration: none;
7 | `;
8 | export const InnerTitle = styled.h1`
9 | font-size: 20px;
10 | margin-bottom: 5px;
11 | font-weight: 400;
12 | `;
13 |
14 | export const TotalCount = styled.h2`
15 | font-size: 16px;
16 | margin-top: 8px;
17 | color: #222;
18 | font-weight: 300;
19 | text-align: right;
20 | `;
21 |
22 | export const Contents = styled.section`
23 | margin: 20px;
24 | hr {
25 | width: 100%;
26 | height: 1px;
27 | display: block;
28 | background-color: #bbb;
29 | border: none;
30 | }
31 | `;
32 |
33 | export const CategoryCho = styled.h2`
34 | font-size: 20px;
35 | margin-bottom: 5px;
36 | font-weight: 400;
37 | margin-left: 6px;
38 | `;
39 |
40 | export const ListItem = styled.li`
41 | list-style-position: inside;
42 | margin: 5px 0px 5px 16px;
43 | `;
44 |
--------------------------------------------------------------------------------
/client/src/components/wiki-section/WikiSection.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect, useReducer } from 'react';
2 | import { useHistory } from 'react-router-dom';
3 | import styled from 'styled-components';
4 |
5 | import { Utils } from '@utils/index';
6 | import { BREAK_POINT_MOBILE } from '@utils/display-width';
7 | import { flexBox } from '@styles/styled-components/mixin';
8 |
9 | import WikiContentsIndex from '@components/common/WikiContentsIndex';
10 | import WikiCard from '@components/wiki-section/wiki-section-components/WikiCard';
11 | import { WikiCategory } from '@components/wiki-section/wiki-section-components/WikiCategory';
12 | import MainSection from '@components/common/MainSection';
13 | import Loading from '@components/common/Loading';
14 | import MdParser from '@components/common/MdParser';
15 | import { docDataReducer, initialDocData } from '@src/reducer/doc-data-reducer';
16 |
17 | const WikiSection = ({ generation, boostcampId, name }: { generation: number; boostcampId: string; name: string }) => {
18 | const [docData, docDispatch] = useReducer(docDataReducer, initialDocData);
19 | const [loading, setLoading] = useState(true);
20 | const history = useHistory();
21 | const id = generation + boostcampId + name;
22 |
23 | useEffect(() => {
24 | const getContent = async () => {
25 | const res = await fetch(`/api/documents/?generation=${generation}&boostcamp_id=${boostcampId}&name=${name}`);
26 | if (res.status === 200) {
27 | const { result } = await res.json();
28 | docDispatch({
29 | type: 'INPUT_DOC_DATA',
30 | payload: { ...result, classification: result.classifications },
31 | });
32 | setLoading(false);
33 | } else if (res.status === 404) {
34 | history.push(`/search?name=${generation}_${boostcampId}_${name}`);
35 | } else {
36 | setLoading(false);
37 | history.push('/error');
38 | }
39 | };
40 |
41 | getContent();
42 | }, [id]);
43 |
44 | return (
45 |
49 | {loading && }
50 | {!loading && (
51 | <>
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | >
60 | )}
61 |
62 | );
63 | };
64 |
65 | const Padd = styled.div`
66 | margin-top: 20px;
67 | margin-left: 10px;
68 | margin-right: 10px;
69 | display: flex;
70 | justify-content: space-between;
71 |
72 | @media only screen and (max-width: ${BREAK_POINT_MOBILE}px) {
73 | ${flexBox({ direction: 'column', alignItems: 'center' })};
74 | }
75 | `;
76 |
77 | export default WikiSection;
78 |
--------------------------------------------------------------------------------
/client/src/components/wiki-section/wiki-section-components/WikiCard.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { BrowserRouter } from 'react-router-dom';
3 | import { screen, render, waitFor } from '@testing-library/react';
4 | import 'jest-styled-components';
5 | import WikiCard from './WikiCard';
6 |
7 | describe('Mount 되자마자 처리되는 렌더링 테스트', () => {
8 | it('docData에 데이터가 없으면 card에 아무것도 보이면 안된다.', async () => {
9 | const name = 'TEST';
10 | render();
11 | await waitFor(() => screen.queryByRole('application', { name: 'card' }));
12 | const card = screen.queryByRole('application', { name: 'card' });
13 | expect(card).toBeNull();
14 | });
15 | });
16 |
17 | describe('useEffect 이후에 처리되는 렌더링 테스트 ', () => {
18 | it('name이 렌더링된다.', async () => {
19 | const docData = {
20 | mbti: 'INTP',
21 | };
22 | const name = 'TEST';
23 | render();
24 | await waitFor(() => screen.getByText(name));
25 | expect(screen.getByText(name)).toBeTruthy();
26 | });
27 |
28 | it('user_image가 있으면 이미지 태그가 렌더링 된다.', async () => {
29 | const docData = {
30 | user_image: 'https://dummy.com/dummy',
31 | };
32 | const name = 'TEST';
33 | render();
34 | await waitFor(() => screen.getByText(name));
35 | const img = screen.getByRole('img', { src: docData.user_image });
36 | expect(img).toBeTruthy();
37 | });
38 |
39 | it('user_image를 제외한 docData에 값이 있다면 렌더링이 된다.', async () => {
40 | const docData = {
41 | nickname: 'nickname',
42 | location: 'location',
43 | language: 'language',
44 | mbti: 'mbti',
45 | field: '',
46 | };
47 | const name = 'TEST';
48 | render();
49 | await waitFor(() => screen.getByText(name));
50 | expect(screen.getByText('별명')).toBeTruthy();
51 | expect(screen.getByText('지역')).toBeTruthy();
52 | expect(screen.getByText('주언어')).toBeTruthy();
53 | expect(screen.getByText('MBTI')).toBeTruthy();
54 | });
55 |
56 | it('링크에 instagram이 있다면 인스타그램 이미지가 로딩된다.', async () => {
57 | const docData = {
58 | link: 'instagram:test',
59 | };
60 | const name = 'TEST';
61 | render(
62 |
63 |
64 | ,
65 | );
66 | await waitFor(() => screen.getByText(name));
67 | const img = screen.getByAltText('link-img');
68 | expect(img).toBeTruthy();
69 | });
70 | });
71 |
--------------------------------------------------------------------------------
/client/src/components/wiki-section/wiki-section-components/WikiCategory.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { BrowserRouter } from 'react-router-dom';
3 | import { render, screen } from '@testing-library/react';
4 | import { WikiCategory } from './WikiCategory';
5 |
6 | describe('', () => {
7 | it('분류가 캠퍼라면 캠퍼가 Document 안에 존재해야한다.', () => {
8 | const categories = ['캠퍼'];
9 | const result = render(
10 |
11 |
12 | ,
13 | );
14 | expect(result.getByText('캠퍼')).toBeInTheDocument();
15 | });
16 |
17 | it('분류가 마스터라면 캠퍼가 Document 안에 존재해야한다.', () => {
18 | const categories = ['마스터'];
19 | const result = render(
20 |
21 |
22 | ,
23 | );
24 | expect(result.getByText('마스터')).toBeInTheDocument();
25 | });
26 |
27 | it('분류가 운영진이면 운영진이 Document 안에 존재해야한다.', () => {
28 | const categories = ['운영진'];
29 | const result = render(
30 |
31 |
32 | ,
33 | );
34 | expect(result.getByText('운영진')).toBeInTheDocument();
35 | });
36 |
37 | it('분류가 리뷰어이면 리뷰어가 Document 안에 존재해야한다.', () => {
38 | const categories = ['리뷰어'];
39 | const result = render(
40 |
41 |
42 | ,
43 | );
44 | expect(result.getByText('리뷰어')).toBeInTheDocument();
45 | });
46 |
47 | it('분류가 멘토이면 멘토가 Document 안에 존재해야한다.', () => {
48 | const categories = ['멘토'];
49 | const result = render(
50 |
51 |
52 | ,
53 | );
54 | expect(result.getByText('멘토')).toBeInTheDocument();
55 | });
56 |
57 | it('분류가 렌더링 되면 분류라는 단어가 문서 내 있어야 한다.', () => {
58 | const categories = [];
59 | const result = render(
60 |
61 |
62 | ,
63 | );
64 | expect(result.getByText('분류')).toBeTruthy();
65 | });
66 |
67 | it('분류가 캠퍼, 7기라면 캠퍼, 7기가 Document 안에 존재해야한다.', () => {
68 | const categories = ['캠퍼', '7기'];
69 | const result = render(
70 |
71 |
72 | ,
73 | );
74 | expect(result.getByText('캠퍼')).toBeInTheDocument();
75 | expect(result.getByText('7기')).toBeInTheDocument();
76 | });
77 | });
78 |
--------------------------------------------------------------------------------
/client/src/components/wiki-section/wiki-section-components/WikiCategory.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router-dom';
3 | import styled from 'styled-components';
4 | import { flexBox } from '@styles/styled-components/mixin';
5 |
6 | const Category = styled.div`
7 | border: 1px solid #bbb;
8 | border-radius: 10px;
9 | height: 30px;
10 | margin-top: 10px;
11 | margin-left: 10px;
12 | margin-right: 10px;
13 | padding: 0px 4px;
14 | ${flexBox({ alignItems: 'center' })}
15 | `;
16 |
17 | const Divider = styled.div`
18 | border-left: 1px solid #d7d7d7;
19 | width: 1px;
20 | height: 22px;
21 | margin: 0px 6px;
22 | `;
23 |
24 | const CategoryLink = styled(Link)`
25 | color: #36a4f3;
26 | text-decoration: none;
27 | `;
28 |
29 | const Flexed = styled.div`
30 | ${flexBox({})}
31 | `;
32 |
33 | export const WikiCategory = ({ categories }: { categories: string[] }): JSX.Element => {
34 | return (
35 |
36 | {' '}
37 | 분류{' '}
38 | {categories.map((item) => (
39 |
40 |
41 | {item}
42 |
43 | ))}
44 |
45 | );
46 | };
47 |
--------------------------------------------------------------------------------
/client/src/event-handler/select-handler.ts:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { removeAccessToken, removeRefreshToken } from '@utils/login';
3 |
4 | export const clickHandler = (
5 | event: any,
6 | selectTgDispatch: React.Dispatch,
7 | SelectTypeDispatch: React.Dispatch,
8 | SelectTgStateRef: any,
9 | ) => {
10 | const classList = event.target.className.split(' ');
11 | if (classList.includes('TgSelect')) {
12 | if (classList.includes('SelectSearchType')) {
13 | selectTgDispatch({ type: 'toggleSearchType' });
14 | } else if (classList.includes('SelectUserInfo')) {
15 | selectTgDispatch({ type: 'toggleUserInfo' });
16 | } else if (classList.includes('SelectPeopleType')) {
17 | selectTgDispatch({ type: 'togglePeopleType' });
18 | } else {
19 | console.error('select toggle error');
20 | }
21 | } else if (classList.includes('SelectRow')) {
22 | if (classList.includes('SelectSearchType')) {
23 | SelectTypeDispatch({ type: 'inputSearchType', value: event.target.innerHTML });
24 | } else if (classList.includes('SelectUserInfo')) {
25 | if (event.target.innerText === '로그인') {
26 | window.location.href = '/login';
27 | } else if (event.target.innerText === '로그아웃') {
28 | removeAccessToken();
29 | removeRefreshToken();
30 | alert('로그아웃이 되었습니다.');
31 | }
32 | } else if (classList.includes('SelectPeopleType')) {
33 | SelectTypeDispatch({ type: 'inputMemberType', value: event.target.innerHTML });
34 | } else {
35 | console.error('select type error');
36 | }
37 | selectTgDispatch({ type: 'allOff' });
38 | } else {
39 | const { isSearchTypeOn, isUserInfoOn, isPeopleTypeOn } = SelectTgStateRef;
40 | if (!isSearchTypeOn && !isUserInfoOn && !isPeopleTypeOn) return;
41 | selectTgDispatch({ type: 'allOff' });
42 | }
43 | };
44 |
--------------------------------------------------------------------------------
/client/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { BrowserRouter } from 'react-router-dom';
4 | import '@styles/scss/index.scss';
5 | import App from './App';
6 | import reportWebVitals from './reportWebVitals';
7 |
8 | ReactDOM.render(
9 |
10 |
11 |
12 |
13 | ,
14 | document.getElementById('root'),
15 | );
16 |
17 | // If you want to start measuring performance in your app, pass a function
18 | // to log results (for example: reportWebVitals(console.log))
19 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
20 | reportWebVitals();
21 |
--------------------------------------------------------------------------------
/client/src/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/pages/CategoryPage.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import CategorySection from '@components/category-section/CategorySection';
3 | import PageLayout from '@pages/common/PageLayout';
4 | import { useHistory } from 'react-router';
5 | import Loading from '@src/components/common/Loading';
6 |
7 | const getDocumentInfo = (pathname: string): { [key: string]: string } | undefined => {
8 | const result = pathname.match(/\/c\/(?.+)/);
9 | return result?.groups;
10 | };
11 |
12 | const CategoryPage = ({ location }: { location: Location }): JSX.Element => {
13 | const history = useHistory();
14 | const [category, setCategory] = useState();
15 | useEffect(() => {
16 | const result = getDocumentInfo(location.pathname);
17 | if (result) setCategory(result.category);
18 | else {
19 | history.push('/error');
20 | }
21 | });
22 | return {category ? : };
23 | };
24 | export default CategoryPage;
25 |
--------------------------------------------------------------------------------
/client/src/pages/ErrorPage.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { Link, useHistory } from 'react-router-dom';
4 | import logo from '@resource/img/logo.png';
5 | import style from '@styles/scss/ErrorPage.module.scss';
6 | import { flexBox } from '@styles/styled-components/mixin';
7 |
8 | const CategoryLink = styled(Link)`
9 | color: #36a4f3;
10 | text-decoration: none;
11 | `;
12 |
13 | const Btn = styled.button`
14 | background: none;
15 | color: inherit;
16 | border: none;
17 | padding: 0;
18 | font: inherit;
19 | cursor: pointer;
20 | outline: inherit;
21 | color: #36a4f3;
22 | `;
23 |
24 | const Flexed = styled.div`
25 | ${flexBox({ justifyContent: 'space-between' })}
26 | width: 100%;
27 | box-sizing: border-box;
28 | `;
29 |
30 | const ErrorPage = () => {
31 | const history = useHistory();
32 |
33 | const goBack = () => {
34 | history.goBack();
35 | };
36 | return (
37 |
38 |
39 |

40 |
41 |
404 Not Found
42 |
페이지를 찾을 수 없습니다
43 |
44 |
45 |
죄송합니다
46 |
관리자에게 문의해주세요
47 |
boocamwiki@gmail.com
48 |
49 |
50 |
51 | 이전 페이지
52 |
53 | 부캠위키 홈
54 |
55 |
56 |
57 | );
58 | };
59 | export default ErrorPage;
60 |
--------------------------------------------------------------------------------
/client/src/pages/GithubCallbackPage.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import { useHistory, useLocation } from 'react-router-dom';
3 | import styled from 'styled-components';
4 | import queryString from 'query-string';
5 | import { font, flexBox } from '@styles/styled-components/mixin';
6 | import cbScss from '@styles/scss/CallbackPage.module.scss';
7 | import { authFetch, setAccessToken, setRefreshToken } from '@utils/login';
8 |
9 | const LoadingWrapper = styled.div`
10 | ${flexBox({ direction: 'column', justifyContent: 'center', alignItems: 'center' })}
11 | width: 100vw;
12 | height: 100vh;
13 | `;
14 |
15 | const LoadingTitle = styled.div`
16 | ${font({ color: '#000000', size: '4rem', weight: 'bold' })}
17 | position: absolute;
18 | top: 52%;
19 | text-align: center;
20 | padding: 0px 20px;
21 | span {
22 | color: #0055fb;
23 | }
24 | `;
25 |
26 | const GithubCallbackPage = () => {
27 | const { search } = useLocation();
28 | const { code } = queryString.parse(search);
29 | const history = useHistory();
30 |
31 | const login = async (code) => {
32 | const res = await authFetch('/api/auth/github', {
33 | method: 'POST',
34 | headers: { 'Content-Type': 'application/json' },
35 | body: JSON.stringify({ code }),
36 | });
37 | const {
38 | result: { accessToken, refreshToken },
39 | msg,
40 | } = await res.json();
41 | if (res.msg === 'fail') {
42 | return history.push('/error');
43 | }
44 | setAccessToken(accessToken);
45 | setRefreshToken(refreshToken);
46 | if (res.status === 200) {
47 | return history.push('/');
48 | }
49 | if (msg === 'nonexistent user') {
50 | return history.push('/join');
51 | }
52 | return history.push('/error');
53 | };
54 |
55 | useEffect(async () => {
56 | login(code);
57 | }, []);
58 |
59 | return (
60 |
61 |
67 |
68 | BOOCAM WIKI
69 |
70 |
71 | );
72 | };
73 |
74 | export default GithubCallbackPage;
75 |
--------------------------------------------------------------------------------
/client/src/pages/JoinPage.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import JoinSection from '@components/join-section/JoinSection';
3 | import { useValidate } from '@utils/login';
4 | import PageLayout from '@pages/common/PageLayout';
5 |
6 | const JoinPage = (): JSX.Element => {
7 | useValidate(false);
8 | return (
9 |
10 |
11 |
12 | );
13 | };
14 |
15 | export default JoinPage;
16 |
--------------------------------------------------------------------------------
/client/src/pages/LoginPage.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import LoginSection from '@components/login-section/LoginSection';
3 | import PageLayout from '@pages/common/PageLayout';
4 |
5 | const LoginPage = (): JSX.Element => {
6 | return (
7 |
8 |
9 |
10 | );
11 | };
12 |
13 | export default LoginPage;
14 |
--------------------------------------------------------------------------------
/client/src/pages/MainPage.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import MainSection from '@components/main-door/MainDoor';
3 | import PageLayout from '@pages/common/PageLayout';
4 |
5 | const MainPage = (): JSX.Element => {
6 | return (
7 |
8 |
9 |
10 | );
11 | };
12 |
13 | export default MainPage;
14 |
--------------------------------------------------------------------------------
/client/src/pages/MakePage.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PageLayout from '@pages/common/PageLayout';
3 | import MakeSection from '@components/make-section/MakeSection';
4 | import { useValidate } from '@utils/login';
5 |
6 | const MakePage = ({ history }: { history: History }): JSX.Element => {
7 | useValidate(true);
8 | return (
9 |
10 |
11 |
12 | );
13 | };
14 |
15 | export default MakePage;
16 |
--------------------------------------------------------------------------------
/client/src/pages/RankPage.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import RankSection from '@components/rank-section/RankSection';
3 | import PageLayout from '@pages/common/PageLayout';
4 |
5 | const LoginPage = (): JSX.Element => {
6 | return (
7 |
8 |
9 |
10 | );
11 | };
12 |
13 | export default LoginPage;
14 |
--------------------------------------------------------------------------------
/client/src/pages/SearchPage.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import SearchSection from '@components/search-section/SearchSection';
3 | import PageLayout from '@pages/common/PageLayout';
4 |
5 | const SearchPage = (): JSX.Element => {
6 | return (
7 |
8 |
9 |
10 | );
11 | };
12 |
13 | export default SearchPage;
14 |
--------------------------------------------------------------------------------
/client/src/pages/TotalDocumentsPage.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import TotalDocumentsSection from '@components/total-documents-section/TotalDocumentsSection';
3 | import PageLayout from '@pages/common/PageLayout';
4 |
5 | const TotalDocumentsPage = (): JSX.Element => {
6 | return (
7 |
8 |
9 |
10 | );
11 | };
12 |
13 | export default TotalDocumentsPage;
14 |
--------------------------------------------------------------------------------
/client/src/pages/UpdatePage.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PageLayout from '@pages/common/PageLayout';
3 | import UpdateSection from '@components/make-section/UpdateSection';
4 | import { useValidate } from '@utils/login';
5 |
6 | const getDocumentInfo = (pathname) => {
7 | const result = pathname.match(/\/updatedocs\/(?\d+)_(?.+)_(?.+)/);
8 | return result.groups;
9 | };
10 |
11 | const UpdatePage = ({ history, location }) => {
12 | useValidate(true);
13 | const result = getDocumentInfo(location.pathname);
14 | return (
15 |
16 |
22 |
23 | );
24 | };
25 |
26 | export default UpdatePage;
27 |
--------------------------------------------------------------------------------
/client/src/pages/WikiPage.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { useHistory } from 'react-router';
3 | import Loading from '@components/common/Loading';
4 | import WikiSection from '@components/wiki-section/WikiSection';
5 | import PageLayout from '@pages/common/PageLayout';
6 |
7 | const getDocumentInfo = (pathname) => {
8 | const result = pathname.match(/\/w\/(?\d+)_(?.+)_(?.+)/);
9 | return result;
10 | };
11 |
12 | const pathnameNorm = (pathname) => {
13 | if (!pathname) return '';
14 | return pathname.startsWith('/w/') ? pathname.substr(3) : pathname;
15 | };
16 |
17 | const WikiPage = ({ location }) => {
18 | const [result, setResult] = useState();
19 | const history = useHistory();
20 | useEffect(() => {
21 | const reg = getDocumentInfo(location.pathname);
22 | if (!reg) {
23 | // 파싱 자체가 안된다면
24 | const pathname = pathnameNorm(location.pathname);
25 | history.push(`/search?name=${pathname}`);
26 | }
27 | // 파싱이라도 된다면
28 | else setResult(reg.groups);
29 | }, [location.pathname]);
30 |
31 | return (
32 |
33 | {result ? (
34 |
35 | ) : (
36 |
37 | )}
38 |
39 | );
40 | };
41 | export default WikiPage;
42 |
--------------------------------------------------------------------------------
/client/src/pages/common/PageLayout.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Header from '@components/header/Header';
3 | import SideSection from '@components/side-section/SideSection';
4 |
5 | import style from '@styles/scss/Page.module.scss';
6 |
7 | const PageLayout = ({ children }) => {
8 | return (
9 |
10 |
11 |
12 | {children}
13 |
14 |
15 |
16 | );
17 | };
18 | export default PageLayout;
19 |
--------------------------------------------------------------------------------
/client/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/client/src/reducer/doc-data-reducer.ts:
--------------------------------------------------------------------------------
1 | import { WordManager } from '@resource/message';
2 |
3 | export interface DocData {
4 | generation: number;
5 | boostcamp_id: string;
6 | name: string;
7 | content: string;
8 | nickname: string;
9 | location: string;
10 | language: string;
11 | user_image: string;
12 | mbti: string;
13 | field: string;
14 | link: string;
15 | member_type: string;
16 | classification: string[];
17 | }
18 |
19 | export const initialDocData = {
20 | generation: -1,
21 | boostcamp_id: '',
22 | name: '',
23 | content: '',
24 | nickname: '',
25 | location: '',
26 | language: '',
27 | user_image: '',
28 | mbti: '',
29 | field: '',
30 | link: '',
31 | member_type: WordManager.CAMPER,
32 | classification: [WordManager.CAMPER],
33 | };
34 |
35 | export const docDataReducer = (state: DocData, action: { type: string; payload: DocData }): DocData => {
36 | switch (action.type) {
37 | case 'INPUT_DOC_DATA':
38 | return { ...state, ...action.payload };
39 | default:
40 | return state;
41 | }
42 | };
43 |
--------------------------------------------------------------------------------
/client/src/reducer/select-toggle-reducer.ts:
--------------------------------------------------------------------------------
1 | interface SelectTgState {
2 | isSearchTypeOn: boolean;
3 | isUserInfoOn: boolean;
4 | isPeopleTypeOn: boolean;
5 | }
6 |
7 | type SelectTgAction =
8 | | { type: 'toggleSearchType' }
9 | | { type: 'toggleUserInfo' }
10 | | { type: 'togglePeopleType' }
11 | | { type: 'allOff' };
12 |
13 | export const selectTgInitState: SelectTgState = {
14 | isSearchTypeOn: false,
15 | isUserInfoOn: false,
16 | isPeopleTypeOn: false,
17 | };
18 |
19 | export const selectTgReducer = (state: SelectTgState, action: SelectTgAction): SelectTgState => {
20 | switch (action.type) {
21 | case 'toggleSearchType':
22 | return {
23 | isSearchTypeOn: !state.isSearchTypeOn,
24 | isUserInfoOn: false,
25 | isPeopleTypeOn: false,
26 | };
27 | case 'toggleUserInfo':
28 | return {
29 | isSearchTypeOn: false,
30 | isUserInfoOn: !state.isUserInfoOn,
31 | isPeopleTypeOn: false,
32 | };
33 | case 'togglePeopleType':
34 | return {
35 | isSearchTypeOn: false,
36 | isUserInfoOn: false,
37 | isPeopleTypeOn: !state.isPeopleTypeOn,
38 | };
39 | case 'allOff':
40 | return {
41 | isSearchTypeOn: false,
42 | isUserInfoOn: false,
43 | isPeopleTypeOn: false,
44 | };
45 | default:
46 | return state;
47 | }
48 | };
49 |
--------------------------------------------------------------------------------
/client/src/reducer/select-type-reducer.ts:
--------------------------------------------------------------------------------
1 | interface SelectTypeState {
2 | searchType: string;
3 | memberType: string;
4 | }
5 |
6 | type Action = { type: 'inputSearchType'; value: string } | { type: 'inputMemberType'; value: string };
7 |
8 | export const selectTypeInitState: SelectTypeState = {
9 | searchType: '이름',
10 | memberType: '',
11 | };
12 |
13 | export const selectTypeReducer = (state: SelectTypeState, action: Action): SelectTypeState => {
14 | switch (action.type) {
15 | case 'inputSearchType':
16 | return {
17 | ...state,
18 | searchType: action.value,
19 | };
20 | case 'inputMemberType':
21 | return {
22 | ...state,
23 | memberType: action.value,
24 | };
25 | default:
26 | return state;
27 | }
28 | };
29 |
--------------------------------------------------------------------------------
/client/src/reportWebVitals.ts:
--------------------------------------------------------------------------------
1 | import { ReportHandler } from 'web-vitals';
2 |
3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => {
4 | if (onPerfEntry && onPerfEntry instanceof Function) {
5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
6 | getCLS(onPerfEntry);
7 | getFID(onPerfEntry);
8 | getFCP(onPerfEntry);
9 | getLCP(onPerfEntry);
10 | getTTFB(onPerfEntry);
11 | });
12 | }
13 | };
14 |
15 | export default reportWebVitals;
16 |
--------------------------------------------------------------------------------
/client/src/resource/img/bold-icon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/resource/img/close.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/client/src/resource/img/drop.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/resource/img/edit.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/web03-boocamWiki/e64e0cfda8ee3da0f5704f116224645566a791e2/client/src/resource/img/edit.png
--------------------------------------------------------------------------------
/client/src/resource/img/genDownBtn.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/resource/img/genUpBtn.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/resource/img/github-white.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/web03-boocamWiki/e64e0cfda8ee3da0f5704f116224645566a791e2/client/src/resource/img/github-white.png
--------------------------------------------------------------------------------
/client/src/resource/img/github.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/web03-boocamWiki/e64e0cfda8ee3da0f5704f116224645566a791e2/client/src/resource/img/github.png
--------------------------------------------------------------------------------
/client/src/resource/img/image-upload-icon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/resource/img/instagram.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/web03-boocamWiki/e64e0cfda8ee3da0f5704f116224645566a791e2/client/src/resource/img/instagram.png
--------------------------------------------------------------------------------
/client/src/resource/img/italic-icon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/resource/img/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/web03-boocamWiki/e64e0cfda8ee3da0f5704f116224645566a791e2/client/src/resource/img/logo.png
--------------------------------------------------------------------------------
/client/src/resource/img/logo2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/web03-boocamWiki/e64e0cfda8ee3da0f5704f116224645566a791e2/client/src/resource/img/logo2.png
--------------------------------------------------------------------------------
/client/src/resource/img/map.svg:
--------------------------------------------------------------------------------
1 |
8 |
--------------------------------------------------------------------------------
/client/src/resource/img/no-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/web03-boocamWiki/e64e0cfda8ee3da0f5704f116224645566a791e2/client/src/resource/img/no-image.png
--------------------------------------------------------------------------------
/client/src/resource/img/rank-page.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/resource/img/rank.svg:
--------------------------------------------------------------------------------
1 |
9 |
--------------------------------------------------------------------------------
/client/src/resource/img/recent.svg:
--------------------------------------------------------------------------------
1 |
8 |
--------------------------------------------------------------------------------
/client/src/resource/img/search.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/client/src/resource/img/ssul-banner.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/web03-boocamWiki/e64e0cfda8ee3da0f5704f116224645566a791e2/client/src/resource/img/ssul-banner.gif
--------------------------------------------------------------------------------
/client/src/resource/img/text-line-icon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/resource/img/total-page.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/resource/img/user.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/client/src/resource/img/world-cup-banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/web03-boocamWiki/e64e0cfda8ee3da0f5704f116224645566a791e2/client/src/resource/img/world-cup-banner.png
--------------------------------------------------------------------------------
/client/src/resource/img/write-page.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/resource/message/index.js:
--------------------------------------------------------------------------------
1 | import WordManager from '@resource/message/words';
2 |
3 | export { WordManager };
4 |
--------------------------------------------------------------------------------
/client/src/resource/message/words.js:
--------------------------------------------------------------------------------
1 | const CAMPER = '캠퍼';
2 | const BOOSTCAMP = '부스트캠프';
3 | const MASTER = '마스터';
4 | const REVIEWER = '리뷰어';
5 | const MENTOR = '멘토';
6 | const MANAGER = '운영진';
7 |
8 | const Eng = {
9 | 캠퍼: 'CAMPER',
10 | 부스트캠프: 'BOOSTCAMP',
11 | 마스터: 'MASTER',
12 | 리뷰어: 'REVIEWER',
13 | 멘토: 'MENTOR',
14 | 운영진: 'MANAGER',
15 | };
16 |
17 | const WordManager = {
18 | CAMPER,
19 | BOOSTCAMP,
20 | MASTER,
21 | REVIEWER,
22 | MENTOR,
23 | MANAGER,
24 | Eng,
25 | };
26 |
27 | export default WordManager;
28 |
--------------------------------------------------------------------------------
/client/src/services/image-upload.js:
--------------------------------------------------------------------------------
1 | import imageCompression from 'browser-image-compression';
2 | import { authFetch } from '@utils/login';
3 | import { fileSizeError, fileFormatError } from '@utils/validator';
4 |
5 | const options = {
6 | maxSizeMB: 1,
7 | maxWidthOrHeight: 1920,
8 | useWebWorker: true,
9 | };
10 |
11 | const imageCompress = async (item) => {
12 | try {
13 | return await imageCompression(item, options);
14 | } catch (error) {
15 | return error;
16 | }
17 | };
18 |
19 | export const getImgUrl = async (item, type = 0) => {
20 | const image = !item.type.match(/gif/) ? await imageCompress(item) : item;
21 | const datas = new FormData();
22 | datas.append('image', image, image.name);
23 | const result = await authFetch('/api/images', {
24 | method: 'POST',
25 | body: datas,
26 | });
27 | const url = await result.json();
28 | const imgUrl = ``;
29 | if (type === 0) {
30 | return imgUrl;
31 | }
32 | return url.imageLink;
33 | };
34 |
35 | export const sendToStorage = async (items) => {
36 | const itemArray = [...items];
37 | const result = await Promise.all(itemArray.map((item) => getImgUrl(item)));
38 | return result;
39 | };
40 |
41 | export const showErrorCode = (errorCode) => {
42 | switch (errorCode) {
43 | case fileFormatError:
44 | alert('image 형식의 파일을 올려주세요');
45 | break;
46 | case fileSizeError:
47 | alert('15MB 이하의 이미지만 올려주세요');
48 | break;
49 | default:
50 | break;
51 | }
52 | };
53 |
--------------------------------------------------------------------------------
/client/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom';
6 |
--------------------------------------------------------------------------------
/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-100.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/web03-boocamWiki/e64e0cfda8ee3da0f5704f116224645566a791e2/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-100.eot
--------------------------------------------------------------------------------
/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-100.svg:
--------------------------------------------------------------------------------
1 | 400: Font family not found400: Missing font family
The requested font families are not available.
Requested: Noto Sans KR (style: normal, weight: 100)
For reference, see the Google Fonts API documentation.
--------------------------------------------------------------------------------
/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-100.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/web03-boocamWiki/e64e0cfda8ee3da0f5704f116224645566a791e2/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-100.woff
--------------------------------------------------------------------------------
/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-100.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/web03-boocamWiki/e64e0cfda8ee3da0f5704f116224645566a791e2/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-100.woff2
--------------------------------------------------------------------------------
/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-300.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/web03-boocamWiki/e64e0cfda8ee3da0f5704f116224645566a791e2/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-300.eot
--------------------------------------------------------------------------------
/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-300.svg:
--------------------------------------------------------------------------------
1 | 400: Font family not found400: Missing font family
The requested font families are not available.
Requested: Noto Sans KR (style: normal, weight: 300)
For reference, see the Google Fonts API documentation.
--------------------------------------------------------------------------------
/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-300.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/web03-boocamWiki/e64e0cfda8ee3da0f5704f116224645566a791e2/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-300.woff
--------------------------------------------------------------------------------
/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-300.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/web03-boocamWiki/e64e0cfda8ee3da0f5704f116224645566a791e2/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-300.woff2
--------------------------------------------------------------------------------
/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-500.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/web03-boocamWiki/e64e0cfda8ee3da0f5704f116224645566a791e2/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-500.eot
--------------------------------------------------------------------------------
/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-500.svg:
--------------------------------------------------------------------------------
1 | 400: Font family not found400: Missing font family
The requested font families are not available.
Requested: Noto Sans KR (style: normal, weight: 500)
For reference, see the Google Fonts API documentation.
--------------------------------------------------------------------------------
/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-500.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/web03-boocamWiki/e64e0cfda8ee3da0f5704f116224645566a791e2/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-500.woff
--------------------------------------------------------------------------------
/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-500.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/web03-boocamWiki/e64e0cfda8ee3da0f5704f116224645566a791e2/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-500.woff2
--------------------------------------------------------------------------------
/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-700.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/web03-boocamWiki/e64e0cfda8ee3da0f5704f116224645566a791e2/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-700.eot
--------------------------------------------------------------------------------
/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-700.svg:
--------------------------------------------------------------------------------
1 | 400: Font family not found400: Missing font family
The requested font families are not available.
Requested: Noto Sans KR (style: normal, weight: 700)
For reference, see the Google Fonts API documentation.
--------------------------------------------------------------------------------
/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-700.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/web03-boocamWiki/e64e0cfda8ee3da0f5704f116224645566a791e2/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-700.woff
--------------------------------------------------------------------------------
/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-700.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/web03-boocamWiki/e64e0cfda8ee3da0f5704f116224645566a791e2/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-700.woff2
--------------------------------------------------------------------------------
/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-900.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/web03-boocamWiki/e64e0cfda8ee3da0f5704f116224645566a791e2/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-900.eot
--------------------------------------------------------------------------------
/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-900.svg:
--------------------------------------------------------------------------------
1 | 400: Font family not found400: Missing font family
The requested font families are not available.
Requested: Noto Sans KR (style: normal, weight: 900)
For reference, see the Google Fonts API documentation.
--------------------------------------------------------------------------------
/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-900.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/web03-boocamWiki/e64e0cfda8ee3da0f5704f116224645566a791e2/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-900.woff
--------------------------------------------------------------------------------
/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-900.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/web03-boocamWiki/e64e0cfda8ee3da0f5704f116224645566a791e2/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-900.woff2
--------------------------------------------------------------------------------
/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-regular.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/web03-boocamWiki/e64e0cfda8ee3da0f5704f116224645566a791e2/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-regular.eot
--------------------------------------------------------------------------------
/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-regular.svg:
--------------------------------------------------------------------------------
1 | 400: Font family not found400: Missing font family
The requested font families are not available.
Requested: Noto Sans KR (style: normal, weight: 400)
For reference, see the Google Fonts API documentation.
--------------------------------------------------------------------------------
/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-regular.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/web03-boocamWiki/e64e0cfda8ee3da0f5704f116224645566a791e2/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-regular.woff
--------------------------------------------------------------------------------
/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/web03-boocamWiki/e64e0cfda8ee3da0f5704f116224645566a791e2/client/src/styles/fonts/noto-sans-kr-v21-latin_korean-regular.woff2
--------------------------------------------------------------------------------
/client/src/styles/scss/CallbackPage.module.scss:
--------------------------------------------------------------------------------
1 | $w: 96px;
2 | $h: 112px;
3 | $xspace: $w/2;
4 | $yspace: $h/4 - 1;
5 | $speed: 1.5s;
6 |
7 | .tetrominos {
8 | position: absolute;
9 | top: 45%;
10 | left: 50%;
11 | transform: translate(-$h, -$w);
12 | }
13 |
14 | .tetromino {
15 | width: $w;
16 | height: $h;
17 | position: absolute;
18 | transition: all ease 0.3s;
19 | background: url('data:image/svg+xml;utf-8,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 612 684"%3E%3Cpath fill="%23e8a20c" d="M305.7 0L0 170.9v342.3L305.7 684 612 513.2V170.9L305.7 0z"/%3E%3Cpath fill="%23fff" d="M305.7 80.1l-233.6 131 233.6 131 234.2-131-234.2-131"/%3E%3C/svg%3E')
20 | no-repeat top center;
21 | }
22 |
23 | .box1 {
24 | animation: tetromino1 $speed ease-out infinite;
25 | }
26 | .box2 {
27 | animation: tetromino2 $speed ease-out infinite;
28 | }
29 | .box3 {
30 | animation: tetromino3 $speed ease-out infinite;
31 | z-index: 2;
32 | }
33 | .box4 {
34 | animation: tetromino4 $speed ease-out infinite;
35 | }
36 |
37 | @keyframes tetromino1 {
38 | 0%,
39 | 40% {
40 | /* compose logo */ /* 1 on 3 */ /* L-shape */
41 | transform: translate(0, 0);
42 | }
43 | 50% {
44 | /* pre-box */
45 | transform: translate($xspace, -$yspace);
46 | }
47 | 60%,
48 | 100% {
49 | /* box */ /* compose logo */
50 | transform: translate($xspace * 2, 0);
51 | }
52 | }
53 |
54 | @keyframes tetromino2 {
55 | 0%,
56 | 20% {
57 | /* compose logo */ /* 1 on 3 */
58 | transform: translate($xspace * 2, 0px);
59 | }
60 | 40%,
61 | 100% {
62 | /* L-shape */ /* box */ /* compose logo */
63 | transform: translate($xspace * 3, $yspace);
64 | }
65 | }
66 |
67 | @keyframes tetromino3 {
68 | 0% {
69 | /* compose logo */
70 | transform: translate($xspace * 3, $yspace);
71 | }
72 | 20%,
73 | 60% {
74 | /* 1 on 3 */ /* L-shape */ /* box */
75 | transform: translate($xspace * 2, $yspace * 2);
76 | }
77 | 90%,
78 | 100% {
79 | /* compose logo */
80 | transform: translate($xspace, $yspace);
81 | }
82 | }
83 |
84 | @keyframes tetromino4 {
85 | 0%,
86 | 60% {
87 | /* compose logo */ /* 1 on 3 */ /* L-shape */ /* box */
88 | transform: translate($xspace, $yspace);
89 | }
90 | 90%,
91 | 100% {
92 | /* compose logo */
93 | transform: translate(0, 0);
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/client/src/styles/scss/ErrorPage.module.scss:
--------------------------------------------------------------------------------
1 | @import './global.scss';
2 |
3 | .ErrorPageContainer {
4 | width: 100%;
5 | height: 100vh;
6 | @include FlexBox();
7 | }
8 |
9 | .ErrorModalWrapper {
10 | @include FlexBox($direction: column, $justify-content: space-between);
11 | width: 500px;
12 | height: 580px;
13 | background: #ffffff;
14 | padding: 10px 60px 40px 60px;
15 | box-shadow: -2px -2px 5px rgba(0, 0, 0, 0.2), 2px 2px 5px rgba(0, 0, 0, 0.25);
16 | border-radius: 40px;
17 |
18 | .logo {
19 | width: 288px;
20 | height: 144px;
21 | }
22 | }
23 |
24 | .ErrorMessageWrapper {
25 | @include FlexBox($direction: column);
26 |
27 | .ErrorMessageEn {
28 | font-family: Noto Sans KR;
29 | font-style: normal;
30 | font-weight: bold;
31 | font-size: 48px;
32 | line-height: 70px;
33 | color: #222222;
34 | }
35 |
36 | .ErrorMessageKr {
37 | font-family: Noto Sans KR;
38 | font-style: normal;
39 | font-weight: 300;
40 | font-size: 32px;
41 | line-height: 46px;
42 | color: #222222;
43 | }
44 | }
45 |
46 | .ErrorGuideWrapper {
47 | @include FlexBox($direction: column, $justify-content: space-between);
48 | font-family: Noto Sans KR;
49 | font-style: normal;
50 | font-weight: normal;
51 | font-size: 24px;
52 | line-height: 35px;
53 | }
54 |
--------------------------------------------------------------------------------
/client/src/styles/scss/Page.module.scss:
--------------------------------------------------------------------------------
1 | @import './global.scss';
2 |
3 | .PageContainer {
4 | width: 100%;
5 | }
6 |
7 | .SectionWrapper {
8 | @include FlexBox($direction: row, $align-items: flex-start);
9 | padding: 20px 0 40px 0;
10 | }
11 |
--------------------------------------------------------------------------------
/client/src/styles/scss/index.scss:
--------------------------------------------------------------------------------
1 | @import './global.scss';
2 |
3 | * {
4 | box-sizing: border-box;
5 | font-family: 'Noto Sans KR', sans-serif;
6 | margin: 0;
7 | padding: 0;
8 | }
9 |
10 | #root {
11 | background: $off-white;
12 | @include FlexBox($direction: row);
13 | }
14 |
--------------------------------------------------------------------------------
/client/src/styles/styled-components/mixin.ts:
--------------------------------------------------------------------------------
1 | import styled, { css } from 'styled-components';
2 |
3 | interface Font {
4 | color: string;
5 | family: string;
6 | size: string;
7 | weight: string;
8 | }
9 |
10 | interface FlexBox {
11 | direction: string;
12 | justifyContent: string;
13 | alignItems: string;
14 | }
15 |
16 | export const font = ({ color, family, size, weight }: Partial) => `
17 | color: ${color || '#222222'};
18 | font-family: ${family || 'Noto Sans KR'};
19 | font-size: ${size || '16px'};
20 | font-weight: ${weight || '500'};
21 | `;
22 |
23 | export const flexBox = ({ direction, justifyContent, alignItems }: Partial) => `
24 | display: flex;
25 | flex-direction: ${direction || 'row'};
26 | justify-content: ${justifyContent || 'flex-start'};
27 | align-items: ${alignItems || 'start'};
28 | `;
29 |
--------------------------------------------------------------------------------
/client/src/types/api-document.ts:
--------------------------------------------------------------------------------
1 | export interface IDocument {
2 | name: string;
3 | boostcampId: string;
4 | generation: number;
5 | timestamp: Date;
6 | content: string;
7 | }
8 |
--------------------------------------------------------------------------------
/client/src/utils/display-width.ts:
--------------------------------------------------------------------------------
1 | export const BREAK_POINT_MOBILE = 768;
2 | export const BREAK_POINT_TABLET = 1024;
3 | export const BREAK_POINT_PC = 1300;
4 |
--------------------------------------------------------------------------------
/client/src/utils/documents.js:
--------------------------------------------------------------------------------
1 | import WordManager from '@resource/message/words';
2 |
3 | const boostcampIdMap = {
4 | MASTER: WordManager.MASTER,
5 | MANAGER: WordManager.MANAGER,
6 | MENTOR: WordManager.MENTOR,
7 | REVIEWER: WordManager.REVIEWER,
8 | };
9 |
10 | export const docTitleGen = ({ generation = 0, name = '', boostcampId }, type) => {
11 | const id = boostcampId ?? '';
12 | const rid = boostcampIdMap[id] ?? (id !== '' ? id : '');
13 | const rgen = Number(generation) === 0 ? '' : `${generation}기`;
14 | const info = [rgen, rid].filter((el) => el !== '').join(' ');
15 |
16 | if (type === 1) {
17 | return info !== '' ? `${info} ${name}` : name;
18 | }
19 | return info !== '' ? `${name} (${info})` : name;
20 | };
21 |
--------------------------------------------------------------------------------
/client/src/utils/index.js:
--------------------------------------------------------------------------------
1 | import { docTitleGen } from '@utils/documents';
2 |
3 | export const Utils = {
4 | docTitleGen,
5 | };
6 |
--------------------------------------------------------------------------------
/client/src/utils/ip-check.js:
--------------------------------------------------------------------------------
1 | const url = 'https://api.ipify.org?format=jsonp&callback=?';
2 | const defaultIP = '0.0.0.0';
3 | export async function fetchIP() {
4 | try {
5 | const response = await fetch(url);
6 | const body = await response.text();
7 | const ip = body.match(/(?\d+\.\d+\.\d+\.\d+)/);
8 | return ip.groups?.ip ?? defaultIP;
9 | } catch {
10 | return defaultIP;
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/client/src/utils/validator.js:
--------------------------------------------------------------------------------
1 | export const fileFormatError = 1;
2 | export const fileSizeError = 2;
3 | export const fileSizeLimit = 15 * 1000000;
4 |
5 | export const fileUploadValidator = (items) => {
6 | const arrayItem = [...items];
7 | const regx = /image/;
8 | if (arrayItem.some((item) => !item.type.match(regx))) {
9 | return fileFormatError;
10 | }
11 | if (arrayItem.some((item) => item.size > fileSizeLimit)) {
12 | return fileSizeError;
13 | }
14 | return 0;
15 | };
16 |
--------------------------------------------------------------------------------
/client/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.path.json",
3 | "compilerOptions": {
4 | "target": "es5",
5 | "lib": [
6 | "dom",
7 | "dom.iterable",
8 | "esnext"
9 | ],
10 | "allowJs": true,
11 | "strictFunctionTypes": false,
12 | "skipLibCheck": true,
13 | "esModuleInterop": true,
14 | "allowSyntheticDefaultImports": true,
15 | "strict": true,
16 | "forceConsistentCasingInFileNames": true,
17 | "noFallthroughCasesInSwitch": true,
18 | "module": "esnext",
19 | "moduleResolution": "node",
20 | "resolveJsonModule": true,
21 | "isolatedModules": true,
22 | "noEmit": true,
23 | "jsx": "react-jsx"
24 | },
25 | "include": [
26 | "src"
27 | ]
28 | }
29 |
--------------------------------------------------------------------------------
/client/tsconfig.path.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": "./src",
4 | "paths": {
5 | "@src/*": ["./*"],
6 | "@components/*": ["components/*"],
7 | "@reducer/*": ["reducer/*"],
8 | "@resource/*": ["resource/*"],
9 | "@pages/*": ["pages/*"],
10 | "@styles/*": ["styles/*"],
11 | "@utils/*": ["utils/*"],
12 | "@types-dir/*": ["types/*"],
13 | "@event-handler/*": ["event-handler/*"],
14 | "@services/*": ["services/*"],
15 | "@select-modal/*": ["components/select-modal/*"]
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/docker/docker-compose.dev.yml:
--------------------------------------------------------------------------------
1 | version: '3.7'
2 |
3 | services:
4 | nginx:
5 | image: nginx
6 | restart: unless-stopped
7 | container_name: dev-boocam-nginx
8 | ports:
9 | - '3002:80'
10 | volumes:
11 | - ./nginx/build:/usr/share/nginx/html
12 | - ./nginx/nginx:/etc/nginx
13 | pm2:
14 | image: keymetrics/pm2:16-jessie
15 | restart: unless-stopped
16 | ports:
17 | - '3003:3001'
18 | volumes:
19 | - ./node/dist:/dist
20 | command: sh -c "ln -sf /usr/share/zoneinfo/Asia/Seoul /etc/localtime && pm2 start /dist/app.js -f -i max && tail -f /dev/null"
21 | mysql:
22 | image: mysql:5.7.36
23 | ports:
24 | - '3307:3006'
25 | restart: unless-stopped
26 | volumes:
27 | - /var/lib/mysql-dev:/var/lib/mysql
28 | environment:
29 | - MYSQL_ROOT_PASSWORD=qnzoadnlzl
30 |
--------------------------------------------------------------------------------
/docker/docker-compose.prod.yml:
--------------------------------------------------------------------------------
1 | version: '3.7'
2 |
3 | services:
4 | nginx:
5 | image: nginx
6 | network_mode: 'host'
7 | restart: unless-stopped
8 | volumes:
9 | - ./nginx/build:/usr/share/nginx/html
10 | - ./nginx/nginx:/etc/nginx
11 | - ./certbot/conf:/etc/letsencrypt
12 | - ./certbot/data:/var/www/certbot
13 | pm2:
14 | image: keymetrics/pm2:16-jessie
15 | network_mode: 'host'
16 | restart: unless-stopped
17 | volumes:
18 | - ./node/dist:/dist
19 | environment:
20 | - DB_PORT=3306
21 | - DB_HOST=localhost
22 | - DB_PASS=qnzoadnlzl
23 | - DB_USER=root
24 | - DB_DB=boocam_wiki
25 | - PORT=3001
26 | - IMG_BUCKET_NAME=boocam-wiki
27 | - IMG_STORAGE_ENDPOINT=https://kr.object.ncloudstorage.com
28 | - ACCESS_KEY=rs79Gh9L4pXVSDeTMRxd
29 | - SECRET_KEY=QObDjOCpQjotxLCa3OtGjwuupDMZpY3DzRXXhqkV
30 | - GITHUB_CLIENT_ID=6081de5d976c1a172081
31 | - GITHUB_SECRET=9085c8783d6ca5579876504b5a7641cbe317ee9f
32 | - ACCESS_TOKEN_SECRET=a9187bcf7bbdcf108dbe702da37d85b37232b89d4c48d53da657696ff8590473
33 | - REFRESH_TOKEN_SECRET=70a0c2d26d743a2965eb785ca6fba6ca0962cd79e52a40a5068e1d1f69780b20
34 | command: sh -c "ln -sf /usr/share/zoneinfo/Asia/Seoul /etc/localtime && pm2 start /dist/app.js -f -i max && tail -f /dev/null"
35 | mysql:
36 | image: mysql:5.7.36
37 | network_mode: 'host'
38 | restart: unless-stopped
39 | volumes:
40 | - /var/lib/mysql:/var/lib/mysql
41 | environment:
42 | - MYSQL_ROOT_PASSWORD=qnzoadnlzl
43 | certbot:
44 | image: certbot/certbot
45 | command: certonly --webroot --webroot-path=/var/www/certbot --email boocamwiki@gmail.com --agree-tos --no-eff-email -d boocamwiki.kr
46 | volumes:
47 | - ./certbot/conf:/etc/letsencrypt
48 | - ./certbot/data:/var/www/certbot
49 | - ./certbot/logs:/var/log/letsencrypt
50 |
--------------------------------------------------------------------------------
/docker/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3.1"
2 |
3 | services:
4 | nginx:
5 | image: nginx
6 | network_mode: "host"
7 | restart: unless-stopped
8 | volumes:
9 | - ./nginx/build:/usr/share/nginx/html
10 | - ./nginx/nginx:/etc/nginx
11 | - ./certbot/conf:/etc/letsencrypt
12 | - ./certbot/data:/var/www/certbot
13 | pm2:
14 | image: keymetrics/pm2:16-jessie
15 | network_mode: "host"
16 | restart: unless-stopped
17 | volumes:
18 | - ./node/dist:/dist
19 | environment:
20 | - DB_PORT=3306
21 | - DB_HOST=localhost
22 | - DB_PASS=qnzoadnlzl
23 | - DB_USER=root
24 | - DB_DB=boocam_wiki
25 | - PORT=3001
26 | - IMG_BUCKET_NAME=boocam-wiki
27 | - IMG_STORAGE_ENDPOINT=https://kr.object.ncloudstorage.com
28 | - ACCESS_KEY=rs79Gh9L4pXVSDeTMRxd
29 | - SECRET_KEY=QObDjOCpQjotxLCa3OtGjwuupDMZpY3DzRXXhqkV
30 | - GITHUB_CLIENT_ID=6081de5d976c1a172081
31 | - GITHUB_SECRET=9085c8783d6ca5579876504b5a7641cbe317ee9f
32 | - ACCESS_TOKEN_SECRET=a9187bcf7bbdcf108dbe702da37d85b37232b89d4c48d53da657696ff8590473
33 | - REFRESH_TOKEN_SECRET=70a0c2d26d743a2965eb785ca6fba6ca0962cd79e52a40a5068e1d1f69780b20
34 | command: sh -c "ln -sf /usr/share/zoneinfo/Asia/Seoul /etc/localtime && pm2 start /dist/app.js -f -i max && tail -f /dev/null"
35 | mysql:
36 | image: mysql:5.7.36
37 | network_mode: "host"
38 | restart: unless-stopped
39 | volumes:
40 | - /var/lib/mysql:/var/lib/mysql
41 | environment:
42 | - MYSQL_ROOT_PASSWORD=qnzoadnlzl
43 | certbot:
44 | image: certbot/certbot
45 | command: certonly --webroot --webroot-path=/var/www/certbot --email boocamwiki@gmail.com --agree-tos --no-eff-email -d boocamwiki.kr
46 | volumes:
47 | - ./certbot/conf:/etc/letsencrypt
48 | - ./certbot/data:/var/www/certbot
49 | - ./certbot/logs:/var/log/letsencrypt
50 |
--------------------------------------------------------------------------------
/docker/nginx-dev/conf.d/default.conf:
--------------------------------------------------------------------------------
1 | server {
2 | listen 80;
3 |
4 | access_log /var/log/nginx/host.access.log main;
5 | root /usr/share/nginx/html;
6 | index index.html;
7 |
8 | location /api {
9 | proxy_pass http://$server_addr:3003;
10 | proxy_http_version 1.1;
11 | proxy_set_header Upgrade $http_upgrade;
12 | proxy_set_header Connection 'upgrade';
13 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
14 | proxy_set_header X-Forwarded-Proto $scheme;
15 | proxy_set_header Host $host;
16 | proxy_cache_bypass $http_upgrade;
17 | }
18 |
19 | location / {
20 | try_files $uri $uri/ /index.html;
21 | }
22 | }
--------------------------------------------------------------------------------
/docker/nginx-dev/fastcgi_params:
--------------------------------------------------------------------------------
1 |
2 | fastcgi_param QUERY_STRING $query_string;
3 | fastcgi_param REQUEST_METHOD $request_method;
4 | fastcgi_param CONTENT_TYPE $content_type;
5 | fastcgi_param CONTENT_LENGTH $content_length;
6 |
7 | fastcgi_param SCRIPT_NAME $fastcgi_script_name;
8 | fastcgi_param REQUEST_URI $request_uri;
9 | fastcgi_param DOCUMENT_URI $document_uri;
10 | fastcgi_param DOCUMENT_ROOT $document_root;
11 | fastcgi_param SERVER_PROTOCOL $server_protocol;
12 | fastcgi_param REQUEST_SCHEME $scheme;
13 | fastcgi_param HTTPS $https if_not_empty;
14 |
15 | fastcgi_param GATEWAY_INTERFACE CGI/1.1;
16 | fastcgi_param SERVER_SOFTWARE nginx/$nginx_version;
17 |
18 | fastcgi_param REMOTE_ADDR $remote_addr;
19 | fastcgi_param REMOTE_PORT $remote_port;
20 | fastcgi_param SERVER_ADDR $server_addr;
21 | fastcgi_param SERVER_PORT $server_port;
22 | fastcgi_param SERVER_NAME $server_name;
23 |
24 | # PHP only, required if PHP was built with --enable-force-cgi-redirect
25 | fastcgi_param REDIRECT_STATUS 200;
26 |
--------------------------------------------------------------------------------
/docker/nginx-dev/nginx.conf:
--------------------------------------------------------------------------------
1 |
2 | user nginx;
3 | worker_processes auto;
4 |
5 | error_log /var/log/nginx/error.log notice;
6 | pid /var/run/nginx.pid;
7 |
8 |
9 | events {
10 | worker_connections 1024;
11 | }
12 |
13 |
14 | http {
15 | include /etc/nginx/mime.types;
16 | default_type application/octet-stream;
17 |
18 | log_format main '$remote_addr - $remote_user [$time_local] "$request" '
19 | '$status $body_bytes_sent "$http_referer" '
20 | '"$http_user_agent" "$http_x_forwarded_for"';
21 |
22 | access_log /var/log/nginx/access.log main;
23 |
24 | sendfile on;
25 | #tcp_nopush on;
26 |
27 | keepalive_timeout 65;
28 |
29 | #gzip on;
30 |
31 | include /etc/nginx/conf.d/*.conf;
32 | }
33 |
--------------------------------------------------------------------------------
/docker/nginx-dev/scgi_params:
--------------------------------------------------------------------------------
1 |
2 | scgi_param REQUEST_METHOD $request_method;
3 | scgi_param REQUEST_URI $request_uri;
4 | scgi_param QUERY_STRING $query_string;
5 | scgi_param CONTENT_TYPE $content_type;
6 |
7 | scgi_param DOCUMENT_URI $document_uri;
8 | scgi_param DOCUMENT_ROOT $document_root;
9 | scgi_param SCGI 1;
10 | scgi_param SERVER_PROTOCOL $server_protocol;
11 | scgi_param REQUEST_SCHEME $scheme;
12 | scgi_param HTTPS $https if_not_empty;
13 |
14 | scgi_param REMOTE_ADDR $remote_addr;
15 | scgi_param REMOTE_PORT $remote_port;
16 | scgi_param SERVER_PORT $server_port;
17 | scgi_param SERVER_NAME $server_name;
18 |
--------------------------------------------------------------------------------
/docker/nginx-dev/uwsgi_params:
--------------------------------------------------------------------------------
1 |
2 | uwsgi_param QUERY_STRING $query_string;
3 | uwsgi_param REQUEST_METHOD $request_method;
4 | uwsgi_param CONTENT_TYPE $content_type;
5 | uwsgi_param CONTENT_LENGTH $content_length;
6 |
7 | uwsgi_param REQUEST_URI $request_uri;
8 | uwsgi_param PATH_INFO $document_uri;
9 | uwsgi_param DOCUMENT_ROOT $document_root;
10 | uwsgi_param SERVER_PROTOCOL $server_protocol;
11 | uwsgi_param REQUEST_SCHEME $scheme;
12 | uwsgi_param HTTPS $https if_not_empty;
13 |
14 | uwsgi_param REMOTE_ADDR $remote_addr;
15 | uwsgi_param REMOTE_PORT $remote_port;
16 | uwsgi_param SERVER_PORT $server_port;
17 | uwsgi_param SERVER_NAME $server_name;
18 |
--------------------------------------------------------------------------------
/docker/nginx-prod/conf.d/default.conf:
--------------------------------------------------------------------------------
1 | server {
2 | listen 80;
3 | server_name boocamwiki.kr www.boocamwiki.kr;
4 |
5 | root /usr/share/nginx/html;
6 | index index.html;
7 |
8 | location / {
9 | return 301 https://$server_name$request_uri;
10 | }
11 |
12 | location ~ /.well-known/acme-challenge {
13 | allow all;
14 | root /var/www/certbot;
15 | }
16 | }
17 |
18 | server {
19 |
20 | listen 443 ssl http2;
21 | server_name boocamwiki.kr www.boocamwiki.kr;
22 |
23 | access_log /var/log/nginx/host.access.log main;
24 | root /usr/share/nginx/html;
25 | index index.html;
26 |
27 | ssl_certificate /etc/letsencrypt/live/boocamwiki.kr/fullchain.pem;
28 | ssl_certificate_key /etc/letsencrypt/live/boocamwiki.kr/privkey.pem;
29 |
30 | location / {
31 | try_files $uri $uri/ /index.html;
32 | }
33 |
34 | location /api {
35 | proxy_pass http://$server_addr:3001;
36 | proxy_http_version 1.1;
37 | proxy_set_header Upgrade $http_upgrade;
38 | proxy_set_header Connection 'upgrade';
39 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
40 | proxy_set_header X-Forwarded-Proto $scheme;
41 | proxy_set_header Host $host;
42 | proxy_cache_bypass $http_upgrade;
43 | }
44 |
45 | location ~* \.(png|jpg|jpeg|gif|svg|woff2|woff|eot)$ {
46 | expires 1y;
47 | add_header Cache-Control "public, no-transform";
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/docker/nginx-prod/fastcgi_params:
--------------------------------------------------------------------------------
1 |
2 | fastcgi_param QUERY_STRING $query_string;
3 | fastcgi_param REQUEST_METHOD $request_method;
4 | fastcgi_param CONTENT_TYPE $content_type;
5 | fastcgi_param CONTENT_LENGTH $content_length;
6 |
7 | fastcgi_param SCRIPT_NAME $fastcgi_script_name;
8 | fastcgi_param REQUEST_URI $request_uri;
9 | fastcgi_param DOCUMENT_URI $document_uri;
10 | fastcgi_param DOCUMENT_ROOT $document_root;
11 | fastcgi_param SERVER_PROTOCOL $server_protocol;
12 | fastcgi_param REQUEST_SCHEME $scheme;
13 | fastcgi_param HTTPS $https if_not_empty;
14 |
15 | fastcgi_param GATEWAY_INTERFACE CGI/1.1;
16 | fastcgi_param SERVER_SOFTWARE nginx/$nginx_version;
17 |
18 | fastcgi_param REMOTE_ADDR $remote_addr;
19 | fastcgi_param REMOTE_PORT $remote_port;
20 | fastcgi_param SERVER_ADDR $server_addr;
21 | fastcgi_param SERVER_PORT $server_port;
22 | fastcgi_param SERVER_NAME $server_name;
23 |
24 | # PHP only, required if PHP was built with --enable-force-cgi-redirect
25 | fastcgi_param REDIRECT_STATUS 200;
26 |
--------------------------------------------------------------------------------
/docker/nginx-prod/nginx.conf:
--------------------------------------------------------------------------------
1 |
2 | user nginx;
3 | worker_processes auto;
4 |
5 | error_log /var/log/nginx/error.log notice;
6 | pid /var/run/nginx.pid;
7 |
8 |
9 | events {
10 | worker_connections 1024;
11 | }
12 |
13 |
14 | http {
15 | include /etc/nginx/mime.types;
16 | default_type application/octet-stream;
17 |
18 | log_format main '$remote_addr - $remote_user [$time_local] "$request" '
19 | '$status $body_bytes_sent "$http_referer" '
20 | '"$http_user_agent" "$http_x_forwarded_for"';
21 |
22 | access_log /var/log/nginx/access.log main;
23 |
24 | sendfile on;
25 | #tcp_nopush on;
26 |
27 | keepalive_timeout 65;
28 |
29 | #gzip on;
30 |
31 | include /etc/nginx/conf.d/*.conf;
32 | }
33 |
--------------------------------------------------------------------------------
/docker/nginx-prod/scgi_params:
--------------------------------------------------------------------------------
1 |
2 | scgi_param REQUEST_METHOD $request_method;
3 | scgi_param REQUEST_URI $request_uri;
4 | scgi_param QUERY_STRING $query_string;
5 | scgi_param CONTENT_TYPE $content_type;
6 |
7 | scgi_param DOCUMENT_URI $document_uri;
8 | scgi_param DOCUMENT_ROOT $document_root;
9 | scgi_param SCGI 1;
10 | scgi_param SERVER_PROTOCOL $server_protocol;
11 | scgi_param REQUEST_SCHEME $scheme;
12 | scgi_param HTTPS $https if_not_empty;
13 |
14 | scgi_param REMOTE_ADDR $remote_addr;
15 | scgi_param REMOTE_PORT $remote_port;
16 | scgi_param SERVER_PORT $server_port;
17 | scgi_param SERVER_NAME $server_name;
18 |
--------------------------------------------------------------------------------
/docker/nginx-prod/uwsgi_params:
--------------------------------------------------------------------------------
1 |
2 | uwsgi_param QUERY_STRING $query_string;
3 | uwsgi_param REQUEST_METHOD $request_method;
4 | uwsgi_param CONTENT_TYPE $content_type;
5 | uwsgi_param CONTENT_LENGTH $content_length;
6 |
7 | uwsgi_param REQUEST_URI $request_uri;
8 | uwsgi_param PATH_INFO $document_uri;
9 | uwsgi_param DOCUMENT_ROOT $document_root;
10 | uwsgi_param SERVER_PROTOCOL $server_protocol;
11 | uwsgi_param REQUEST_SCHEME $scheme;
12 | uwsgi_param HTTPS $https if_not_empty;
13 |
14 | uwsgi_param REMOTE_ADDR $remote_addr;
15 | uwsgi_param REMOTE_PORT $remote_port;
16 | uwsgi_param SERVER_PORT $server_port;
17 | uwsgi_param SERVER_NAME $server_name;
18 |
--------------------------------------------------------------------------------
/docker/nginx/nginx/conf.d/default.conf:
--------------------------------------------------------------------------------
1 | server {
2 | listen 80;
3 | server_name boocamwiki.kr www.boocamwiki.kr;
4 |
5 | root /usr/share/nginx/html;
6 | index index.html;
7 |
8 | location / {
9 | return 301 https://$server_name$request_uri;
10 | }
11 |
12 | location ~ /.well-known/acme-challenge {
13 | allow all;
14 | root /var/www/certbot;
15 | }
16 | }
17 |
18 | server {
19 |
20 | listen 443 ssl http2;
21 | server_name boocamwiki.kr www.boocamwiki.kr;
22 |
23 | access_log /var/log/nginx/host.access.log main;
24 | root /usr/share/nginx/html;
25 | index index.html;
26 |
27 | ssl_certificate /etc/letsencrypt/live/boocamwiki.kr/fullchain.pem;
28 | ssl_certificate_key /etc/letsencrypt/live/boocamwiki.kr/privkey.pem;
29 |
30 | location / {
31 | try_files $uri $uri/ /index.html;
32 | }
33 |
34 | location /api {
35 | proxy_pass http://$server_addr:3001;
36 | proxy_http_version 1.1;
37 | proxy_set_header Upgrade $http_upgrade;
38 | proxy_set_header Connection 'upgrade';
39 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
40 | proxy_set_header X-Forwarded-Proto $scheme;
41 | proxy_set_header Host $host;
42 | proxy_cache_bypass $http_upgrade;
43 | }
44 |
45 | location ~* \.(png|jpg|jpeg|gif|svg|woff2|woff|eot)$ {
46 | expires 1y;
47 | add_header Cache-Control "public, no-transform";
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/docker/nginx/nginx/conf.d/default.dev.conf:
--------------------------------------------------------------------------------
1 | server {
2 | listen 80;
3 | server_name boocamwiki.kr www.boocamwiki.kr;
4 |
5 | root /usr/share/nginx/html;
6 | index index.html;
7 |
8 | location / {
9 | return 301 https://$server_name$request_uri;
10 | }
11 |
12 | location ~ /.well-known/acme-challenge {
13 | allow all;
14 | root /var/www/certbot;
15 | }
16 | }
17 |
18 | server {
19 |
20 | listen 443 ssl http2;
21 | server_name boocamwiki.kr www.boocamwiki.kr;
22 |
23 | access_log /var/log/nginx/host.access.log main;
24 | root /usr/share/nginx/html;
25 | index index.html;
26 |
27 | ssl_certificate /etc/letsencrypt/live/boocamwiki.kr/fullchain.pem;
28 | ssl_certificate_key /etc/letsencrypt/live/boocamwiki.kr/privkey.pem;
29 |
30 | location / {
31 | try_files $uri $uri/ /index.html;
32 | }
33 |
34 | location /api {
35 | proxy_pass http://$server_addr:3001;
36 | proxy_http_version 1.1;
37 | proxy_set_header Upgrade $http_upgrade;
38 | proxy_set_header Connection 'upgrade';
39 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
40 | proxy_set_header X-Forwarded-Proto $scheme;
41 | proxy_set_header Host $host;
42 | proxy_cache_bypass $http_upgrade;
43 | }
44 |
45 | location ~* \.(png|jpg|jpeg|gif|svg|woff2|woff|eot)$ {
46 | expires 1y;
47 | add_header Cache-Control "public, no-transform";
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/docker/nginx/nginx/fastcgi_params:
--------------------------------------------------------------------------------
1 |
2 | fastcgi_param QUERY_STRING $query_string;
3 | fastcgi_param REQUEST_METHOD $request_method;
4 | fastcgi_param CONTENT_TYPE $content_type;
5 | fastcgi_param CONTENT_LENGTH $content_length;
6 |
7 | fastcgi_param SCRIPT_NAME $fastcgi_script_name;
8 | fastcgi_param REQUEST_URI $request_uri;
9 | fastcgi_param DOCUMENT_URI $document_uri;
10 | fastcgi_param DOCUMENT_ROOT $document_root;
11 | fastcgi_param SERVER_PROTOCOL $server_protocol;
12 | fastcgi_param REQUEST_SCHEME $scheme;
13 | fastcgi_param HTTPS $https if_not_empty;
14 |
15 | fastcgi_param GATEWAY_INTERFACE CGI/1.1;
16 | fastcgi_param SERVER_SOFTWARE nginx/$nginx_version;
17 |
18 | fastcgi_param REMOTE_ADDR $remote_addr;
19 | fastcgi_param REMOTE_PORT $remote_port;
20 | fastcgi_param SERVER_ADDR $server_addr;
21 | fastcgi_param SERVER_PORT $server_port;
22 | fastcgi_param SERVER_NAME $server_name;
23 |
24 | # PHP only, required if PHP was built with --enable-force-cgi-redirect
25 | fastcgi_param REDIRECT_STATUS 200;
26 |
--------------------------------------------------------------------------------
/docker/nginx/nginx/nginx.conf:
--------------------------------------------------------------------------------
1 |
2 | user nginx;
3 | worker_processes auto;
4 |
5 | error_log /var/log/nginx/error.log notice;
6 | pid /var/run/nginx.pid;
7 |
8 |
9 | events {
10 | worker_connections 1024;
11 | }
12 |
13 |
14 | http {
15 | include /etc/nginx/mime.types;
16 | default_type application/octet-stream;
17 |
18 | log_format main '$remote_addr - $remote_user [$time_local] "$request" '
19 | '$status $body_bytes_sent "$http_referer" '
20 | '"$http_user_agent" "$http_x_forwarded_for"';
21 |
22 | access_log /var/log/nginx/access.log main;
23 |
24 | sendfile on;
25 | #tcp_nopush on;
26 |
27 | keepalive_timeout 65;
28 |
29 | #gzip on;
30 |
31 | include /etc/nginx/conf.d/*.conf;
32 | }
33 |
--------------------------------------------------------------------------------
/docker/nginx/nginx/scgi_params:
--------------------------------------------------------------------------------
1 |
2 | scgi_param REQUEST_METHOD $request_method;
3 | scgi_param REQUEST_URI $request_uri;
4 | scgi_param QUERY_STRING $query_string;
5 | scgi_param CONTENT_TYPE $content_type;
6 |
7 | scgi_param DOCUMENT_URI $document_uri;
8 | scgi_param DOCUMENT_ROOT $document_root;
9 | scgi_param SCGI 1;
10 | scgi_param SERVER_PROTOCOL $server_protocol;
11 | scgi_param REQUEST_SCHEME $scheme;
12 | scgi_param HTTPS $https if_not_empty;
13 |
14 | scgi_param REMOTE_ADDR $remote_addr;
15 | scgi_param REMOTE_PORT $remote_port;
16 | scgi_param SERVER_PORT $server_port;
17 | scgi_param SERVER_NAME $server_name;
18 |
--------------------------------------------------------------------------------
/docker/nginx/nginx/uwsgi_params:
--------------------------------------------------------------------------------
1 |
2 | uwsgi_param QUERY_STRING $query_string;
3 | uwsgi_param REQUEST_METHOD $request_method;
4 | uwsgi_param CONTENT_TYPE $content_type;
5 | uwsgi_param CONTENT_LENGTH $content_length;
6 |
7 | uwsgi_param REQUEST_URI $request_uri;
8 | uwsgi_param PATH_INFO $document_uri;
9 | uwsgi_param DOCUMENT_ROOT $document_root;
10 | uwsgi_param SERVER_PROTOCOL $server_protocol;
11 | uwsgi_param REQUEST_SCHEME $scheme;
12 | uwsgi_param HTTPS $https if_not_empty;
13 |
14 | uwsgi_param REMOTE_ADDR $remote_addr;
15 | uwsgi_param REMOTE_PORT $remote_port;
16 | uwsgi_param SERVER_PORT $server_port;
17 | uwsgi_param SERVER_NAME $server_name;
18 |
--------------------------------------------------------------------------------
/package-lock.json:
--------------------------------------------------------------------------------
1 | {
2 | "lockfileVersion": 1
3 | }
4 |
--------------------------------------------------------------------------------
/server/.env.example:
--------------------------------------------------------------------------------
1 | DB_PORT=
2 | DB_HOST=
3 | DB_PASS=
4 | DB_USER=
5 | DB_DB=
6 | PORT=
7 | IMG_BUCKET_NAME=
8 | IMG_STORAGE_ENDPOINT=
9 | ACCESS_KEY=
10 | SECRET_KEY=
11 | GITHUB_CLIENT_ID=
12 | GITHUB_SECRET=
13 | ACCESS_TOKEN_SECRET=
14 | REFRESH_TOKEN_SECRET=
--------------------------------------------------------------------------------
/server/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | parser: '@typescript-eslint/parser',
3 | plugins: ['@typescript-eslint', 'prettier'],
4 | extends: [
5 | 'plugin:import/errors',
6 | 'plugin:import/warnings',
7 | 'plugin:prettier/recommended',
8 | 'plugin:@typescript-eslint/recommended',
9 | 'prettier/@typescript-eslint',
10 | ],
11 | rules: {
12 | 'linebreak-style': 0,
13 | 'import/prefer-default-export': 0,
14 | 'prettier/prettier': 0,
15 | 'import/extensions': 0,
16 | 'no-use-before-define': 0,
17 | 'import/no-unresolved': 0,
18 | 'import/no-extraneous-dependencies': 0, // 테스트 또는 개발환경을 구성하는 파일에서는 devDependency 사용을 허용
19 | 'no-shadow': 0,
20 | 'react/prop-types': 0,
21 | 'react/jsx-filename-extension': [2, { extensions: ['.js', '.jsx', '.ts', '.tsx'] }],
22 | 'jsx-a11y/no-noninteractive-element-interactions': 0,
23 | },
24 | };
25 |
--------------------------------------------------------------------------------
/server/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "semi": true,
4 | "useTabs": false,
5 | "tabWidth": 2,
6 | "trailingComma": "all",
7 | "printWidth": 120,
8 | "arrowParens": "always"
9 | }
--------------------------------------------------------------------------------
/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "devDependencies": {
3 | "@types/express": "^4.17.13",
4 | "@types/jsonwebtoken": "^8.5.6",
5 | "@types/mysql": "^2.15.19",
6 | "@types/node": "^16.11.6",
7 | "@typescript-eslint/eslint-plugin": "^5.2.0",
8 | "@typescript-eslint/parser": "^5.2.0",
9 | "eslint": "^8.1.0",
10 | "eslint-config-prettier": "^8.3.0",
11 | "eslint-plugin-import": "^2.25.2",
12 | "eslint-plugin-prettier": "^4.0.0",
13 | "nodemon": "^2.0.14",
14 | "prettier": "^2.4.1",
15 | "ts-node": "^10.4.0",
16 | "tsc-watch": "^4.5.0",
17 | "typescript": "^4.4.4"
18 | },
19 | "dependencies": {
20 | "aws-sdk": "^2.348.0",
21 | "axios": "^0.24.0",
22 | "bcrypt": "^5.0.1",
23 | "dotenv": "^10.0.0",
24 | "express": "^4.17.1",
25 | "express-validator": "^6.13.0",
26 | "hangul-js": "^0.2.6",
27 | "jsonwebtoken": "^8.5.1",
28 | "morgan": "^1.10.0",
29 | "multer": "^1.4.3",
30 | "mysql2": "^2.3.2",
31 | "uuid": "^8.3.2"
32 | },
33 | "scripts": {
34 | "start": "tsc-watch --onSuccess \" node dist/app.js\"",
35 | "build": "tsc",
36 | "postbuild": "cp package.json dist/package.json && cp package-lock.json dist/package-lock.json && cp .env dist/env && cd dist && npm ci --production && mkdir -p ../../docker/node/dist && cp -r * ../../docker/node/dist"
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/server/src/api/auth.ts:
--------------------------------------------------------------------------------
1 | import * as express from 'express';
2 | import * as jwt from 'jsonwebtoken';
3 | import config from '../config';
4 | import { generateAccessToken, generateToken, getAccessToken, getUserInfo } from '../services/login';
5 | import { getUser, insertUser } from '../sql/users-query';
6 | import { jwtAuthCheck, jwtRefreshCheck } from './middleware';
7 |
8 | const router = express.Router();
9 |
10 | router.post('/github', async (req: express.Request, res: express.Response) => {
11 | try {
12 | const { code } = req.body;
13 | const accessToken = await getAccessToken(code);
14 | const { login, node_id, avatar_url } = await getUserInfo(accessToken);
15 | const userInfo = { login, node_id, avatar_url };
16 | if (await getUser(userInfo.node_id)) {
17 | const tokens = generateToken({ ...userInfo, validation: true });
18 | return res.status(200).json({ result: { ...tokens }, msg: 'success' });
19 | } else {
20 | const tokens = generateToken({ ...userInfo, validation: false });
21 | return res.status(404).json({ result: { ...tokens }, msg: 'nonexistent user' });
22 | }
23 | } catch (err) {
24 | return res.status(404).json({ result: {}, msg: 'fail' });
25 | }
26 | });
27 |
28 | router.post('/join', jwtAuthCheck(false), async (req: express.Request, res: express.Response) => {
29 | try {
30 | const { login, node_id, avatar_url } = req.jwt;
31 | const userInfo = { login, node_id, avatar_url };
32 | const tokens = generateToken({ ...userInfo, validation: true });
33 | await insertUser({ node_id, login, avatar_url });
34 | return res.status(200).json({ result: { ...tokens }, msg: 'success' });
35 | } catch (err) {
36 | return res.status(404).json({ result: {}, msg: 'fail' });
37 | }
38 | });
39 |
40 | router.get('/refresh', jwtRefreshCheck, async (req: express.Request, res: express.Response) => {
41 | try {
42 | const [, refreshToken] = req.headers.authorization.split('Bearer ');
43 | const { node_id } = jwt.verify(refreshToken, config.REFRESH_TOKEN_SECRET) as { node_id: string };
44 | const userInfo = await getUser(node_id);
45 | const accessToken = generateAccessToken({ ...userInfo, validation: true });
46 | return res.status(200).json({ result: { accessToken }, msg: 'success' });
47 | } catch (err) {
48 | return res.status(404).json({ result: {}, msg: 'fail' });
49 | }
50 | });
51 |
52 | export default router;
53 |
--------------------------------------------------------------------------------
/server/src/api/categories.ts:
--------------------------------------------------------------------------------
1 | import * as express from 'express';
2 | import { packDataWithName } from '../services/words';
3 | import {
4 | getDocumentsWithClassification,
5 | getCountsWithClassification,
6 | getAllClassifications,
7 | } from '../sql/classification-query';
8 | import { getSignedInt } from '../services/util';
9 | const router = express.Router();
10 |
11 | router.get('/:classification_id', async (req: express.Request, res: express.Response) => {
12 | try {
13 | const step = 30;
14 | const cid = req.params.classification_id;
15 | let offset = getSignedInt(req.query.offset?.toString() ?? '');
16 | const count = await getCountsWithClassification(cid);
17 | offset = Math.min(offset, Math.floor(count / step + (count % step ? 1 : 0)));
18 | offset = Math.max(1, offset);
19 | const result = await getDocumentsWithClassification(cid, offset, step);
20 | const packed = packDataWithName(result);
21 | const classifications = await getAllClassifications();
22 | res.status(200).json({
23 | result: {
24 | classifications,
25 | count,
26 | list: packed,
27 | offset,
28 | },
29 | msg: 'success',
30 | });
31 | } catch (err) {
32 | console.log(err);
33 | return res.status(404).json({ result: [], msg: 'fail' });
34 | }
35 | });
36 |
37 | export default router;
38 |
--------------------------------------------------------------------------------
/server/src/api/documents.test.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/web03-boocamWiki/e64e0cfda8ee3da0f5704f116224645566a791e2/server/src/api/documents.test.ts
--------------------------------------------------------------------------------
/server/src/api/images.ts:
--------------------------------------------------------------------------------
1 | import * as express from 'express';
2 | import * as AWS from 'aws-sdk';
3 | import * as multer from 'multer';
4 | import config from '../config';
5 | import { v4 as uuidv4 } from 'uuid';
6 | import { jwtAuthCheck } from './middleware';
7 |
8 | const router = express.Router();
9 |
10 | const upload = multer();
11 |
12 | router.post('/', jwtAuthCheck(true), upload.single('image'), async (req: any, res: express.Response) => {
13 | const S3 = new AWS.S3({
14 | endpoint: config.IMG_STORAGE_ENDPOINT,
15 | region: 'kr-standard',
16 | credentials: {
17 | accessKeyId: config.ACCESS_KEY,
18 | secretAccessKey: config.SECRET_KEY,
19 | },
20 | });
21 | const imageName = uuidv4();
22 | await S3.putObject({
23 | Bucket: config.IMG_BUCKET_NAME,
24 | Key: `${imageName}.PNG`,
25 | ACL: 'public-read',
26 | Body: req.file.buffer,
27 | ContentType: 'image/png',
28 | }).promise();
29 |
30 | res.status(200).json({
31 | imageLink: `${config.IMG_STORAGE_ENDPOINT}/${config.IMG_BUCKET_NAME}/${imageName}.PNG`,
32 | });
33 | });
34 |
35 | export default router;
36 |
--------------------------------------------------------------------------------
/server/src/api/index.ts:
--------------------------------------------------------------------------------
1 | import * as express from 'express';
2 | import documentRouter from './documents';
3 | import imageRouter from './images';
4 | import authRouter from './auth';
5 | import categoryRouter from './categories';
6 | import rankRouter from './rank';
7 |
8 | const router = express.Router();
9 | router.use('/documents', documentRouter);
10 | router.use('/images', imageRouter);
11 | router.use('/auth', authRouter);
12 | router.use('/categories', categoryRouter);
13 | router.use('/rank', rankRouter);
14 | export default router;
15 |
--------------------------------------------------------------------------------
/server/src/api/middleware.ts:
--------------------------------------------------------------------------------
1 | import * as express from 'express';
2 | import * as jwt from 'jsonwebtoken';
3 | import config from '../config';
4 | import { TokenPayload } from '../types/apiInterface';
5 |
6 | export function jwtAuthCheck(validation: boolean = true) {
7 | return (req: express.Request, res: express.Response, next: express.NextFunction) => {
8 | if (!req.headers.authorization) {
9 | return res.status(401).json({ msg: 'Authorization Header is not existed' });
10 | }
11 | try {
12 | const [, token] = req.headers.authorization.split('Bearer ');
13 | const payload: TokenPayload = jwt.verify(token, config.ACCESS_TOKEN_SECRET) as TokenPayload;
14 | if (payload.validation !== validation) {
15 | return res.status(401).json({ msg: 'Unexpected Token' });
16 | }
17 | req.jwt = payload;
18 | return next();
19 | } catch (err) {
20 | if (err.name === 'TokenExpiredError') {
21 | return res.status(419).json({
22 | msg: 'Token is expired',
23 | });
24 | }
25 | return res.status(401).json({
26 | msg: 'Unavailable Token',
27 | });
28 | }
29 | };
30 | }
31 |
32 | export function jwtRefreshCheck(req: express.Request, res: express.Response, next: express.NextFunction) {
33 | if (!req.headers.authorization) {
34 | return res.status(401).json({ msg: 'Authorization Header is not existed' });
35 | }
36 | try {
37 | const [, token] = req.headers.authorization.split('Bearer ');
38 | jwt.verify(token, config.REFRESH_TOKEN_SECRET);
39 | return next();
40 | } catch (err) {
41 | if (err.name === 'TokenExpiredError') {
42 | return res.status(419).json({
43 | msg: 'Token is expired',
44 | });
45 | }
46 | return res.status(401).json({
47 | msg: 'Unavailable Token',
48 | });
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/server/src/api/rank.ts:
--------------------------------------------------------------------------------
1 | import * as express from 'express';
2 | import { getMbtiCount, getContributionCount } from '../sql/rank-query';
3 | const router = express.Router();
4 |
5 | router.get('/mbti', async (req: express.Request, res: express.Response) => {
6 | try {
7 | const count = await getMbtiCount();
8 | return res.status(200).json({
9 | result: count,
10 | msg: 'success',
11 | });
12 | } catch (err) {
13 | console.log(err);
14 | return res.status(404).json({ result: {}, msg: 'fail to get mbti rank' });
15 | }
16 | });
17 |
18 | router.get('/contribution', async (req: express.Request, res: express.Response) => {
19 | try {
20 | const count = await getContributionCount();
21 | return res.status(200).json({
22 | result: count,
23 | msg: 'success',
24 | });
25 | } catch (err) {
26 | console.log(err);
27 | return res.status(404).json({ result: {}, msg: 'fail to get contribution rank' });
28 | }
29 | });
30 |
31 | export default router;
32 |
--------------------------------------------------------------------------------
/server/src/app.ts:
--------------------------------------------------------------------------------
1 | import loaders, {dbLoader} from './loaders';
2 | import * as express from 'express';
3 | import config from './config'
4 |
5 | async function startServer() {
6 | const app = express();
7 |
8 | await loaders({ expressApp: app });
9 | await dbLoader({});
10 |
11 | app.listen(config.PORT, () => {
12 | console.log(`✅ Your server is ready !`);
13 | });
14 | }
15 |
16 | startServer();
17 |
--------------------------------------------------------------------------------
/server/src/config/index.ts:
--------------------------------------------------------------------------------
1 | import * as env from 'dotenv';
2 | env.config();
3 | export default {
4 | DB_PORT: process.env.DB_PORT,
5 | DB_HOST: process.env.DB_HOST,
6 | DB_PASS: process.env.DB_PASS,
7 | DB_USER: process.env.DB_USER,
8 | DB_DB: process.env.DB_DB,
9 | PORT: process.env.PORT,
10 | IMG_STORAGE_ENDPOINT: process.env.IMG_STORAGE_ENDPOINT,
11 | ACCESS_KEY: process.env.ACCESS_KEY,
12 | SECRET_KEY: process.env.SECRET_KEY,
13 | IMG_BUCKET_NAME: process.env.IMG_BUCKET_NAME,
14 | GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID,
15 | GITHUB_SECRET: process.env.GITHUB_SECRET,
16 | ACCESS_TOKEN_SECRET: process.env.ACCESS_TOKEN_SECRET,
17 | REFRESH_TOKEN_SECRET: process.env.REFRESH_TOKEN_SECRET,
18 | };
19 |
--------------------------------------------------------------------------------
/server/src/jobs/crontab.sh:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/web03-boocamWiki/e64e0cfda8ee3da0f5704f116224645566a791e2/server/src/jobs/crontab.sh
--------------------------------------------------------------------------------
/server/src/loaders/express.ts:
--------------------------------------------------------------------------------
1 | import * as express from 'express';
2 | import * as morgan from 'morgan';
3 | import router from '../api';
4 |
5 | export default async ({ app }: { app: express.Application }) => {
6 | app.get('/status', (req, res) => {
7 | res.status(200).end();
8 | });
9 | app.head('/status', (req, res) => {
10 | res.status(200).end();
11 | });
12 | app.use((req, res, next) => {
13 | res.header('Access-Control-Allow-Origin', '*');
14 | res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
15 | res.header('Access-Control-Allow-Headers', 'Content-Type');
16 | next();
17 | });
18 | app.enable('trust proxy');
19 | app.use(express.json());
20 | app.use(morgan('dev'));
21 | app.use(express.urlencoded({ extended: false }));
22 | app.use('/api', router);
23 | return app;
24 | };
25 |
--------------------------------------------------------------------------------
/server/src/loaders/index.ts:
--------------------------------------------------------------------------------
1 | import expressLoader from './express';
2 | import mysqlLoader from './mysql';
3 |
4 | export default async ({ expressApp }): Promise => {
5 | await expressLoader({ app: expressApp });
6 | console.log('Express Intialized');
7 | };
8 |
9 | export async function dbLoader({}): Promise {
10 | await mysqlLoader();
11 | console.log('DB connected');
12 | }
13 |
--------------------------------------------------------------------------------
/server/src/loaders/mysql.ts:
--------------------------------------------------------------------------------
1 | import * as mysql from 'mysql2/promise';
2 | import config from '../config';
3 | import db from '../services/db-pool';
4 |
5 | export default async () => {
6 | const pool = mysql.createPool({
7 | host: config.DB_HOST,
8 | port: parseInt(config.DB_PORT),
9 | user: config.DB_USER,
10 | password: config.DB_PASS,
11 | database: config.DB_DB,
12 | });
13 | db.pool = pool;
14 | };
15 |
--------------------------------------------------------------------------------
/server/src/services/db-pool.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | pool: undefined,
3 | async conn() {
4 | return await this.pool?.getConnection();
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/server/src/services/index.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/web03-boocamWiki/e64e0cfda8ee3da0f5704f116224645566a791e2/server/src/services/index.ts
--------------------------------------------------------------------------------
/server/src/services/login.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import * as jwt from 'jsonwebtoken';
3 | import config from '../config';
4 | import { GithubUserInfo, TokenPayload } from '../types/apiInterface';
5 |
6 | export async function getAccessToken(code: string): Promise {
7 | if (code === undefined) {
8 | throw new Error('code is not existed');
9 | }
10 | const clientId = config.GITHUB_CLIENT_ID;
11 | const secret = config.GITHUB_SECRET;
12 |
13 | const TOKEN_URL = `https://github.com/login/oauth/access_token?client_id=${clientId}&client_secret=${secret}&code=${code}`;
14 | const { data } = await axios.post(TOKEN_URL);
15 |
16 | const searchParams = new URLSearchParams(data);
17 | const accessToken = searchParams.get('access_token');
18 |
19 | return accessToken;
20 | }
21 |
22 | export async function getUserInfo(accessToken: string): Promise {
23 | if (accessToken === undefined) {
24 | throw new Error('accessToken is not existed');
25 | }
26 | const USER_PROFILE_URL = 'https://api.github.com/user';
27 | const { data: userInformation } = await axios.get(USER_PROFILE_URL, {
28 | headers: {
29 | Authorization: `token ${accessToken}`,
30 | },
31 | });
32 | return userInformation as GithubUserInfo;
33 | }
34 |
35 | export function generateAccessToken(TokenPayload: TokenPayload): string {
36 | return jwt.sign({ ...TokenPayload }, config.ACCESS_TOKEN_SECRET, { algorithm: 'HS256', expiresIn: '15m' });
37 | }
38 |
39 | export function generateRefreshToken(node_id: string): string {
40 | return jwt.sign({ node_id }, config.REFRESH_TOKEN_SECRET, { algorithm: 'HS256', expiresIn: '1 days' });
41 | }
42 |
43 | export function generateToken(TokenPayload: TokenPayload): { accessToken: string; refreshToken: string } {
44 | const accessToken = generateAccessToken(TokenPayload);
45 | const refreshToken = generateRefreshToken(TokenPayload.node_id);
46 | return { accessToken, refreshToken };
47 | }
48 |
--------------------------------------------------------------------------------
/server/src/services/util.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Document,
3 | DocumentsCreate,
4 | DocumentsUpdate,
5 | keyofDocumentsCreate,
6 | keyofDocumentsUpdate,
7 | } from '../types/apiInterface';
8 |
9 | export function getObjectKey(arg: object): string[] {
10 | return Object.entries(arg)
11 | .filter(([, value]) => value !== undefined && value !== null)
12 | .map(([key]) => key);
13 | }
14 |
15 | export function getObjectValue(arg: object, stringTypeList: String[]): string[] {
16 | return Object.entries(arg)
17 | .filter(([, value]) => value !== undefined && value !== null)
18 | .map(([, value]) => (!stringTypeList.includes(value) ? `\'${value}\'` : value));
19 | }
20 |
21 | export function getDocumentsCreateObj(param: DocumentsCreate): object {
22 | const result = {};
23 | Object.entries(keyofDocumentsCreate).forEach(([key]) => (result[key] = param[key]));
24 | return result;
25 | }
26 |
27 | export function getDocumentsUpdateObj(param: DocumentsUpdate): object {
28 | const result = {};
29 | Object.entries(keyofDocumentsUpdate).forEach(([key]) => (result[key] = param[key]));
30 | return result;
31 | }
32 |
33 | export function getDocumentKeyValue(arg: object, stringTypeList: String[], append: String = undefined): String[] {
34 | return Object.entries(arg)
35 | .filter(([, value]) => value !== undefined && value !== null)
36 | .map(([key, value]) => {
37 | let _key = key;
38 | if (append && append.length > 0) {
39 | _key = `${append}${key}`;
40 | }
41 | return `${_key}=${!stringTypeList.includes(key) ? `'${value}'` : value}`;
42 | });
43 | }
44 |
45 | export function getSignedInt(str: string, baseNumber: number = 1): number {
46 | let result = 1;
47 | try {
48 | result = parseInt(str);
49 | if (isNaN(result) || result < baseNumber) result = baseNumber;
50 | } catch {}
51 | return result;
52 | }
53 |
54 | export function intToIp(ip) {
55 | return [24, 16, 8, 0].map((n) => (ip >> n) & 0xff).join('.');
56 | }
57 |
58 | export function ipToInt(ip) {
59 | return ip.split('.').reduce((sum, x, i) => sum + (x << (8 * (3 - i))), 0) >>> 0;
60 | }
61 |
--------------------------------------------------------------------------------
/server/src/services/words.ts:
--------------------------------------------------------------------------------
1 | import * as Hangul from 'hangul-js';
2 | export function isHangulChar(ch) {
3 | const c = ch.charCodeAt(0);
4 | if (0x1100 <= c && c <= 0x11ff) return true;
5 | if (0x3130 <= c && c <= 0x318f) return true;
6 | if (0xac00 <= c && c <= 0xd7a3) return true;
7 | return false;
8 | }
9 |
10 | export function getHangulCho(str) {
11 | const result = Hangul.d(str);
12 | return result[0];
13 | }
14 |
15 | export function packDataWithName(obj) {
16 | const packed = {};
17 | let c;
18 | obj.forEach((item) => {
19 | if (isHangulChar(item.name)) c = getHangulCho(item.name);
20 | else c = item.name[0];
21 |
22 | if (packed[c]) packed[c].push(item);
23 | else packed[c] = [item];
24 | });
25 | return packed;
26 | }
27 |
--------------------------------------------------------------------------------
/server/src/sql/classification-query.ts:
--------------------------------------------------------------------------------
1 | import { DocumentsClassification } from '../types/apiInterface';
2 | import db from '../services/db-pool';
3 |
4 | export async function updateClassification(param: DocumentsClassification) {
5 | const insertAllCl = param.classification.map(async (cl) => {
6 | const query = `INSERT IGNORE INTO \`classification\`(classification_id) VALUES (?)`;
7 | const [result] = await db.pool.query(query, [cl]);
8 | return result;
9 | });
10 |
11 | await Promise.all(insertAllCl);
12 |
13 | param.classification.forEach(async (cl) => {
14 | const query =
15 | `INSERT INTO \`document_classification\` (classification_id, generation, boostcamp_id, name) VALUES ` +
16 | `(?, ?, ?, ?)`;
17 | const [result] = await db.pool.query(query, [cl, param.generation, param.boostcamp_id, param.name]);
18 | return result;
19 | });
20 | }
21 |
22 | export async function getDocumentsWithClassification(classification: string, offset: number, offStep: number) {
23 | const query =
24 | `SELECT doc.boostcamp_id as boostcamp_id, doc.generation as generation, doc.name as name ` +
25 | `FROM document as doc JOIN document_classification as cl ON ` +
26 | `doc.generation = cl.generation AND doc.boostcamp_id = cl.boostcamp_id AND ` +
27 | `doc.name = cl.name WHERE cl.classification_id = ? ORDER BY doc.name LIMIT ? OFFSET ?`;
28 | const [result] = await db.pool.query(query, [classification, offStep, (offset - 1) * offStep]);
29 | return result;
30 | }
31 |
32 | export async function getCountsWithClassification(classification: string) {
33 | const query =
34 | `SELECT count(*) as count ` +
35 | `FROM document as doc JOIN document_classification as cl ON ` +
36 | `doc.generation = cl.generation AND doc.boostcamp_id = cl.boostcamp_id AND ` +
37 | `doc.name = cl.name WHERE cl.classification_id = ?`;
38 | const result = (await db.pool.query(query, classification))[0][0].count;
39 | return result;
40 | }
41 |
42 | export async function getAllClassifications() {
43 | const query = `SELECT * FROM classification`;
44 | let [result] = await db.pool.query(query);
45 | result = result.map((item) => item.classification_id);
46 | return result;
47 | }
48 |
--------------------------------------------------------------------------------
/server/src/sql/index.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/web03-boocamWiki/e64e0cfda8ee3da0f5704f116224645566a791e2/server/src/sql/index.ts
--------------------------------------------------------------------------------
/server/src/sql/rank-query.ts:
--------------------------------------------------------------------------------
1 | import db from '../services/db-pool';
2 |
3 | export async function getMbtiCount() {
4 | const query =
5 | `SELECT mbti, count(*) as count ` +
6 | `FROM document ` +
7 | `WHERE mbti is not null and mbti != 'null' and mbti != '' ` +
8 | `GROUP BY mbti ` +
9 | `ORDER BY count DESC;`;
10 |
11 | const [result] = await db.pool.query(query);
12 | return result;
13 | }
14 |
15 | export async function getContributionCount() {
16 | const query =
17 | `SELECT up.user_id, count(*) as count ` +
18 | `FROM boocam_wiki.update as up INNER JOIN boocam_wiki.user as us ` +
19 | `ON up.user_id = us.user_id ` +
20 | `GROUP BY up.user_id ` +
21 | `ORDER BY count DESC ` +
22 | `LIMIT 10;`;
23 |
24 | const [result] = await db.pool.query(query);
25 | return result;
26 | }
27 |
--------------------------------------------------------------------------------
/server/src/sql/users-query.ts:
--------------------------------------------------------------------------------
1 | import db from '../services/db-pool';
2 | import { GithubUserInfo } from '../types/apiInterface';
3 |
4 | export async function getUser(node_id: string): Promise {
5 | const query = `SELECT user_id as node_id, login, avatar_url FROM \`user\` WHERE user_id=?`;
6 | const [result] = await db.pool.query(query, [node_id]);
7 | return result?.[0] as GithubUserInfo;
8 | }
9 |
10 | export async function insertUser({ node_id, login, avatar_url }: GithubUserInfo): Promise {
11 | const query = `INSERT INTO \`user\`(user_id, login, avatar_url) VALUES (?, ?, ?)`;
12 | await db.pool.query(query, [node_id, login, avatar_url]);
13 | }
14 |
--------------------------------------------------------------------------------
/server/src/subscribers/document-subscriber.ts:
--------------------------------------------------------------------------------
1 | import { updateClassification } from '../sql/classification-query';
2 | import { increaseViewCount, updateRecentDoc } from '../sql/documents-query';
3 | import { DocumentsClassification, DocumentsCreate, Document, DocumentsUpdate } from '../types/apiInterface';
4 | export function OnDocCreate(body: DocumentsCreate) {
5 | updateRecentDoc(body as DocumentsUpdate);
6 | updateClassification(body as DocumentsClassification);
7 | }
8 |
9 | export async function OnDocViewed({ boostcamp_id, generation, name }: Partial) {
10 | increaseViewCount({ boostcamp_id, generation, name });
11 | }
12 |
--------------------------------------------------------------------------------
/server/src/subscribers/index.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2021/web03-boocamWiki/e64e0cfda8ee3da0f5704f116224645566a791e2/server/src/subscribers/index.ts
--------------------------------------------------------------------------------
/server/src/types/apiInterface.ts:
--------------------------------------------------------------------------------
1 | export interface Document {
2 | generation: number;
3 | boostcamp_id: string;
4 | name: string;
5 | }
6 |
7 | export interface DocumentsSearch extends Document {
8 | content: string;
9 | offset: number;
10 | limit: number;
11 | }
12 |
13 | export interface DocumentsCreate extends Document {
14 | content: String;
15 | nickname: String;
16 | language: String;
17 | location: String;
18 | field: String;
19 | mbti: String;
20 | link: String;
21 | user_image: String;
22 | }
23 |
24 | export interface DocumentsUpdate extends DocumentsCreate {
25 | user_id: String;
26 | ip: String;
27 | }
28 |
29 | export interface DocumentsClassification extends DocumentsCreate {
30 | classification: string[];
31 | }
32 |
33 | export interface DocumentsView extends Document {
34 | total_count: String;
35 | }
36 |
37 | export interface DocumentsRecent extends Document {
38 | recent_created_at: String;
39 | }
40 |
41 | export const keyofDocumentsCreate = {
42 | generation: 0,
43 | boostcamp_id: 0,
44 | name: 0,
45 | content: 0,
46 | nickname: 0,
47 | language: 0,
48 | location: 0,
49 | field: 0,
50 | mbti: 0,
51 | link: 0,
52 | user_image: 0,
53 | };
54 |
55 | export const keyofDocumentsUpdate = {
56 | generation: 0,
57 | boostcamp_id: 0,
58 | name: 0,
59 | content: 0,
60 | nickname: 0,
61 | language: 0,
62 | location: 0,
63 | field: 0,
64 | mbti: 0,
65 | link: 0,
66 | user_image: 0,
67 | user_id: 0,
68 | ip: 0,
69 | };
70 |
71 | export interface GithubUserInfo {
72 | login: string;
73 | node_id: string;
74 | avatar_url: string;
75 | }
76 |
77 | export type TokenPayload = { validation: boolean; iat?: number; exp?: number } & GithubUserInfo;
78 |
79 | export type DocumentsConcurrencyValidation = { generation; boostcamp_id; name; updated_at? };
80 | export enum DocConcurrencyState {
81 | DOCDEFAULT = 0,
82 | DOCERASED = 1,
83 | DOCUPDATED = 2,
84 | DOCCREATED = 3,
85 | }
86 |
--------------------------------------------------------------------------------
/server/src/types/d.ts:
--------------------------------------------------------------------------------
1 | import { TokenPayload } from './apiInterface';
2 |
3 | declare global {
4 | namespace Express {
5 | interface Request {
6 | jwt?: TokenPayload;
7 | }
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES5",
4 | "module": "CommonJS",
5 | "outDir": "./dist",
6 | "typeRoots": ["./node_modules/@types", "./src/types"]
7 | },
8 | "include": ["src/**/*.ts"],
9 | "exclude": ["node_modules"]
10 | }
11 |
--------------------------------------------------------------------------------