├── .DS_Store ├── .github ├── ISSUE_TEMPLATE │ └── request.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── API_CI.yml │ ├── CLIENT_CI.yml │ ├── COMPILER_CI.yml │ ├── SOCKET_CI.yml │ ├── deploy.yml │ └── lighthouse.yaml ├── .gitignore ├── README.md ├── backend ├── .DS_Store ├── .gitignore ├── api │ ├── .eslintrc.js │ ├── .gitignore │ ├── .prettierrc │ ├── Dockerfile │ ├── README.md │ ├── nest-cli.json │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── app.controller.ts │ │ ├── app.module.ts │ │ ├── app.service.ts │ │ ├── auth │ │ │ ├── auth.controller.ts │ │ │ ├── auth.module.ts │ │ │ ├── auth.service.ts │ │ │ ├── github.strategy.ts │ │ │ ├── jwt-auth.guard.ts │ │ │ ├── jwt.strategy.ts │ │ │ ├── local.strategy.ts │ │ │ └── optional-jwt-auth.guard.ts │ │ ├── bookmark │ │ │ ├── bookmark.controller.ts │ │ │ ├── bookmark.entity.ts │ │ │ ├── bookmark.module.ts │ │ │ ├── bookmark.service.ts │ │ │ └── dto │ │ │ │ ├── bookmark-create.dto.ts │ │ │ │ └── bookmark-response.dto.ts │ │ ├── document │ │ │ ├── document.controller.spec.ts │ │ │ ├── document.controller.ts │ │ │ ├── document.entity.ts │ │ │ ├── document.module.ts │ │ │ ├── document.service.ts │ │ │ └── dto │ │ │ │ ├── document-create.dto.ts │ │ │ │ ├── document-detail-response.dto.ts │ │ │ │ ├── document-response.dto.ts │ │ │ │ └── document-update.dto.ts │ │ ├── enum │ │ │ └── role.enum.ts │ │ ├── filters │ │ │ ├── all-exception.filter.ts │ │ │ └── http-exception.filter.ts │ │ ├── main.ts │ │ ├── pipes │ │ │ └── validation.pipe.ts │ │ ├── redis │ │ │ └── redis.module.ts │ │ ├── types │ │ │ └── char.d.ts │ │ ├── user │ │ │ ├── dto │ │ │ │ ├── user-create.dto.ts │ │ │ │ ├── user-response.dto.ts │ │ │ │ └── user-update.dto.ts │ │ │ ├── user.controller.ts │ │ │ ├── user.entity.ts │ │ │ ├── user.module.ts │ │ │ └── user.service.ts │ │ └── userdocument │ │ │ ├── dto │ │ │ ├── userdocument-create.dto.ts │ │ │ ├── userdocument-response.dto.ts │ │ │ └── userdocument-update.dto.ts │ │ │ ├── userdocument.controller.ts │ │ │ ├── userdocument.entity.ts │ │ │ ├── userdocument.module.ts │ │ │ └── userdocument.service.ts │ ├── test │ │ ├── app.e2e-spec.ts │ │ ├── jest-e2e.json │ │ └── user.e2e-spec.ts │ ├── tsconfig.build.json │ └── tsconfig.json ├── compiler │ ├── .eslintrc.js │ ├── .prettierrc │ ├── index.ts │ ├── package-lock.json │ ├── package.json │ ├── src │ │ └── index.ts │ └── tsconfig.json └── socket │ ├── .eslintrc.js │ ├── .prettierrc │ ├── Dockerfile │ ├── crdt-linear-ll │ ├── char.ts │ └── crdt.ts │ ├── crdt-linear-server │ ├── char.ts │ └── crdt.ts │ ├── index.ts │ ├── package-lock.json │ ├── package.json │ ├── tsconfig.json │ └── types │ └── crdt.d.ts ├── benchmark ├── .gitignore ├── crdt-array │ ├── crdt │ │ ├── char.ts │ │ └── crdt.ts │ ├── localDeleteTest.ts │ ├── localInsertRangeTest.ts │ ├── localInsertTest.ts │ ├── main.ts │ ├── remoteDeleteRangeTest.ts │ ├── remoteDeleteTest.ts │ ├── remoteInsertRangeTest.ts │ └── remoteInsertTest.ts ├── package-lock.json ├── package.json └── tsconfig.json ├── conf.d ├── nginx.blue.conf └── nginx.green.conf ├── deploy.sh ├── docker-compose.blue.yaml ├── docker-compose.db.yaml ├── docker-compose.green.yaml ├── docker-compose.nginx.yaml ├── ecosystem.config.js ├── frontend ├── .eslintrc.json ├── .gitignore ├── .lighthouserc.json ├── .prettierrc.json ├── .template.env ├── Dockerfile ├── README.md ├── electron │ └── main.ts ├── forge.config.js ├── package-lock.json ├── package.json ├── public │ ├── apple-touch-icon-180x180.png │ ├── apple-touch-icon-57x57.png │ ├── favicon.svg │ ├── index.html │ ├── manifest.json │ ├── mockServiceWorker.js │ └── robots.txt ├── src │ ├── App.tsx │ ├── GlobalStyles.ts │ ├── Router.tsx │ ├── assets │ │ ├── 404.svg │ │ ├── angle-down.svg │ │ ├── background-1.svg │ │ ├── background-2.svg │ │ ├── bookmark.svg │ │ ├── check.svg │ │ ├── codocs.icns │ │ ├── exclamation.svg │ │ ├── github.svg │ │ ├── house.svg │ │ ├── lock.svg │ │ ├── logo.svg │ │ ├── online.svg │ │ ├── pencil.svg │ │ ├── refly.svg │ │ ├── together.svg │ │ ├── trash.svg │ │ └── trashbag.svg │ ├── atoms │ │ ├── onlineUserAtom.ts │ │ └── toastMsgAtom.ts │ ├── components │ │ ├── docList │ │ │ ├── DocList.test.tsx │ │ │ ├── DocList.tsx │ │ │ └── index.ts │ │ ├── docListItem │ │ │ ├── DocListItem.tsx │ │ │ └── index.ts │ │ ├── dropdown │ │ │ ├── Dropdown.tsx │ │ │ ├── DropdownItem.tsx │ │ │ ├── DropdownMenu.tsx │ │ │ ├── DropdownTrigger.tsx │ │ │ └── index.ts │ │ ├── dropdownOption │ │ │ ├── DropdownOption.tsx │ │ │ └── index.ts │ │ ├── editor │ │ │ ├── Editor.tsx │ │ │ └── index.ts │ │ ├── editorHeader │ │ │ ├── EditorHeader.tsx │ │ │ └── index.ts │ │ ├── header │ │ │ ├── Header.tsx │ │ │ └── index.ts │ │ ├── iconButton │ │ │ ├── IconButton.tsx │ │ │ └── index.ts │ │ ├── loginButton │ │ │ ├── LoginButton.tsx │ │ │ └── index.ts │ │ ├── modal │ │ │ ├── Modal.tsx │ │ │ ├── ModalPortal.tsx │ │ │ └── index.ts │ │ ├── modalForm │ │ │ ├── ModalForm.tsx │ │ │ └── index.ts │ │ ├── onlineUser │ │ │ ├── OnlineUser.tsx │ │ │ └── index.ts │ │ ├── sideBar │ │ │ ├── SideBar.tsx │ │ │ └── index.ts │ │ ├── siteLogo │ │ │ ├── SiteLogo.tsx │ │ │ └── index.ts │ │ ├── spinner │ │ │ ├── Spinner.tsx │ │ │ └── index.ts │ │ ├── themeSwitcher │ │ │ ├── ThemeSwitcher.tsx │ │ │ └── index.ts │ │ ├── toastMsg │ │ │ ├── ToastMsg.tsx │ │ │ ├── ToastPortal.tsx │ │ │ └── index.ts │ │ └── userProfile │ │ │ ├── UserProfile.tsx │ │ │ └── index.ts │ ├── constants │ │ ├── breakpoints.ts │ │ ├── modalContent.ts │ │ └── styled.ts │ ├── core │ │ ├── crdt-linear-ll │ │ │ ├── char.ts │ │ │ ├── crdt.test.ts │ │ │ └── crdt.ts │ │ ├── crdt-linear │ │ │ ├── char.ts │ │ │ ├── crdt.test.ts │ │ │ └── crdt.ts │ │ ├── cursor │ │ │ └── cursor.ts │ │ ├── editorWithCRDT │ │ │ └── editorWithCRDT.ts │ │ └── sockets │ │ │ └── sockets.ts │ ├── hooks │ │ ├── useDarkMode.ts │ │ ├── useDebounce.ts │ │ ├── useDocumentTitle.ts │ │ ├── useModal.ts │ │ ├── usePageName.ts │ │ ├── useSortOption.ts │ │ └── useToast.ts │ ├── index.css │ ├── index.tsx │ ├── mocks │ │ ├── dummy │ │ │ ├── charMap.ts │ │ │ ├── docList.ts │ │ │ ├── document.ts │ │ │ └── userProfile.ts │ │ ├── handler.ts │ │ ├── server.ts │ │ └── worker.ts │ ├── pages │ │ ├── BookmarkPage.tsx │ │ ├── DocumentPage.tsx │ │ ├── LandingPage.tsx │ │ ├── MainLayout.tsx │ │ ├── MainPage.tsx │ │ ├── NotFoundPage.tsx │ │ ├── PrivatePage.tsx │ │ └── SharedPage.tsx │ ├── query │ │ ├── document │ │ │ ├── useAddDocumentBookmarkMutation.tsx │ │ │ ├── useDeleteDocumentMutation.tsx │ │ │ ├── useDocumentDataQuery.tsx │ │ │ ├── useGetDocumentQuery.tsx │ │ │ └── useRemoveDocumentBookmarkMutation.tsx │ │ └── profile │ │ │ └── useGetProfileQuery.tsx │ ├── react-app-env.d.ts │ ├── setupTests.ts │ ├── theme.ts │ ├── types │ │ ├── crdt.d.ts │ │ ├── document.d.ts │ │ ├── eventHandler.d.ts │ │ ├── onlineUser.d.ts │ │ ├── style.d.ts │ │ └── userProfile.d.ts │ └── utils │ │ ├── fetchBeforeRender.ts │ │ └── utils.ts └── tsconfig.json ├── package-lock.json └── package.json /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web16-Codocs/2508e2ede509c00e7929d0afc6a285548df911b9/.DS_Store -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### 설명 11 | --- 12 | 13 | ### 완료 조건 14 | --- 15 | - [ ] 16 | - [ ] 17 | - [ ] 18 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### PR 타입(하나 이상의 PR 타입을 선택해주세요) 2 | - [ ] 기능 추가 3 | - [ ] 기능 삭제 4 | - [ ] 버그 수정 5 | - [ ] 의존성, 환경 변수, 빌드 관련 코드 업데이트 6 | 7 | ### 관련 이슈 8 | - 9 | 10 | ### 변경 사항 11 | ex) 로그인 시, 구글 소셜 로그인 기능을 추가했습니다. 12 | 13 | ### 동작 확인 14 | ex) 베이스 브랜치에 포함되기 위한 코드는 모두 정상적으로 동작해야 합니다. 결과물에 대한 스크린샷, GIF, 혹은 라이브 데모가 가능하도록 샘플API를 첨부할 수도 있습니다. 15 | -------------------------------------------------------------------------------- /.github/workflows/API_CI.yml: -------------------------------------------------------------------------------- 1 | name: API CI 2 | 3 | on: 4 | pull_request: 5 | branches: ['dev'] 6 | paths: 7 | - 'backend/api/**' 8 | workflow_dispatch: 9 | 10 | jobs: 11 | api_test: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | node-version: [18.x] 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | cache: 'npm' 23 | cache-dependency-path: './backend/api/package-lock.json' 24 | 25 | - run: | 26 | cd ./backend/api 27 | npm ci 28 | npm test 29 | -------------------------------------------------------------------------------- /.github/workflows/CLIENT_CI.yml: -------------------------------------------------------------------------------- 1 | name: CLIENT CI 2 | 3 | on: 4 | pull_request: 5 | branches: ['dev'] 6 | paths: 7 | - 'frontend/**' 8 | workflow_dispatch: 9 | 10 | jobs: 11 | client_test: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | node-version: [18.x] 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | cache: 'npm' 23 | cache-dependency-path: './frontend/package-lock.json' 24 | 25 | - name: Setting .env.development 26 | run: | 27 | cd ./frontend 28 | echo "REACT_APP_GITHUB_OAUTH=${{ secrets.REACT_APP_GITHUB_OAUTH_DEV }}" >> .env.development 29 | echo "REACT_APP_NODE_ENV=${{ secrets.REACT_APP_NODE_ENV_DEV }}" >> .env.development 30 | echo "REACT_APP_API_URL=${{ secrets.REACT_APP_API_URL_DEV }}" >> .env.development 31 | echo "REACT_APP_SOCKET_URL=${{ secrets.REACT_APP_SOCKET_URL_DEV }}" >> .env.development 32 | echo "BROWSER=${{ secrets.BROWSER }}" >> .env.development 33 | cat .env.development 34 | 35 | - name: Install and Test 36 | run: | 37 | cd ./frontend 38 | npm ci 39 | npm run test 40 | env: 41 | CI: false 42 | -------------------------------------------------------------------------------- /.github/workflows/COMPILER_CI.yml: -------------------------------------------------------------------------------- 1 | name: COMPILER CI 2 | 3 | on: 4 | pull_request: 5 | branches: ['dev'] 6 | paths: 7 | - 'backend/compiler/**' 8 | workflow_dispatch: 9 | 10 | jobs: 11 | compiler_test: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | node-version: [18.x] 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | cache: 'npm' 23 | cache-dependency-path: './backend/compiler/package-lock.json' 24 | 25 | - run: | 26 | cd ./backend/compiler 27 | npm ci 28 | npm test 29 | -------------------------------------------------------------------------------- /.github/workflows/SOCKET_CI.yml: -------------------------------------------------------------------------------- 1 | name: SOCKET CI 2 | 3 | on: 4 | pull_request: 5 | branches: ['dev'] 6 | paths: 7 | - 'backend/socket/**' 8 | workflow_dispatch: 9 | 10 | jobs: 11 | socket_test: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | node-version: [18.x] 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | cache: 'npm' 23 | cache-dependency-path: './backend/socket/package-lock.json' 24 | 25 | - run: | 26 | cd ./backend/socket 27 | npm ci 28 | npm test 29 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: 'deploy when push' 2 | 3 | on: 4 | push: 5 | branches: ['dev'] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout source code. 14 | uses: actions/checkout@v3 15 | 16 | - name: executing remote ssh commands using password 17 | uses: appleboy/ssh-action@master 18 | with: 19 | host: ${{ secrets.SSH_HOST }} 20 | username: ${{ secrets.SSH_USERNAME }} 21 | password: ${{ secrets.SSH_PASSWORD }} 22 | port: ${{ secrets.SSH_PORT }} 23 | script: | 24 | cd /root/codocs/ && git checkout dev && git pull 25 | ./deploy.sh 26 | 27 | -------------------------------------------------------------------------------- /.github/workflows/lighthouse.yaml: -------------------------------------------------------------------------------- 1 | name: Run lighthouse CI When Pull Request 2 | 3 | on: 4 | pull_request: 5 | branches: ['dev'] 6 | paths: 7 | - 'frontend/**' 8 | workflow_dispatch: 9 | 10 | jobs: 11 | lhci: 12 | name: Lighthouse CI 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v3 17 | 18 | - name: Use Node.js 18.7.0 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: 18.7.0 22 | 23 | - name: Install packages 24 | run: | 25 | cd ./frontend 26 | npm ci 27 | 28 | - name: Setting .env.production 29 | run: | 30 | cd ./frontend 31 | echo "REACT_APP_GITHUB_OAUTH=${{ secrets.REACT_APP_GITHUB_OAUTH_PROD }}" >> .env.production 32 | echo "REACT_APP_NODE_ENV=${{ secrets.REACT_APP_NODE_ENV_PROD }}" >> .env.production 33 | echo "REACT_APP_API_URL=${{ secrets.REACT_APP_API_URL_PROD }}" >> .env.production 34 | echo "REACT_APP_SOCKET_URL=${{ secrets.REACT_APP_SOCKET_URL_PROD }}" >> .env.production 35 | echo "BROWSER=${{ secrets.BROWSER }}" >> .env.production 36 | cat .env.production 37 | 38 | - name: Build 39 | run: | 40 | cd ./frontend 41 | npm run build 42 | env: 43 | CI: false 44 | 45 | - name: Run Lighthouse CI 46 | env: 47 | LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }} 48 | run: | 49 | cd ./frontend 50 | npm install -g @lhci/cli 51 | lhci autorun || echo "🚨 Fail to Run Lighthouse CI!" 52 | 53 | - name: Format lighthouse score 54 | id: format_lighthouse_score 55 | uses: iyu88/lighthouse-report-formatter@1.0.0 56 | with: 57 | lh_directory: ./frontend/ 58 | manifest_path: lhci_reports 59 | 60 | - name: comment PR 61 | uses: unsplash/comment-on-pr@v1.3.0 62 | env: 63 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 64 | with: 65 | msg: ${{ steps.format_lighthouse_score.outputs.comments}} 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env* -------------------------------------------------------------------------------- /backend/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web16-Codocs/2508e2ede509c00e7929d0afc6a285548df911b9/backend/.DS_Store -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /backend/api/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module' 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'], 10 | root: true, 11 | env: { 12 | node: true, 13 | jest: true 14 | }, 15 | ignorePatterns: ['.eslintrc.js'], 16 | rules: { 17 | '@typescript-eslint/interface-name-prefix': 'off', 18 | '@typescript-eslint/explicit-function-return-type': 'off', 19 | '@typescript-eslint/explicit-module-boundary-types': 'off', 20 | '@typescript-eslint/no-explicit-any': 'off', 21 | 'prettier/prettier': [ 22 | 'error', 23 | { 24 | endOfLine: 'auto' 25 | } 26 | ] 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /backend/api/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json -------------------------------------------------------------------------------- /backend/api/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "trailingComma": "none", 4 | "tabWidth": 2, 5 | "semi": true, 6 | "singleQuote": true, 7 | "useTabs": false, 8 | "bracketSpacing": true, 9 | "bracketSameLine": true, 10 | "arrowParens": "always", 11 | "endOfLine": "auto", 12 | "parser": "typescript" 13 | } 14 | -------------------------------------------------------------------------------- /backend/api/Dockerfile: -------------------------------------------------------------------------------- 1 | From node:16-alpine 2 | 3 | WORKDIR /app 4 | 5 | COPY package.json package-lock.json ./ 6 | 7 | COPY . ./ 8 | 9 | RUN npm ci 10 | 11 | ENTRYPOINT [ "/usr/local/bin/npm" , "start"] -------------------------------------------------------------------------------- /backend/api/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src" 5 | } 6 | -------------------------------------------------------------------------------- /backend/api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "prebuild": "rimraf dist", 10 | "build": "nest build", 11 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 12 | "start": "env-cmd -f .env.production nest start", 13 | "start:dev": "env-cmd -f .env.development nest start --watch", 14 | "start:debug": "nest start --debug --watch", 15 | "start:prod": "node dist/main", 16 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 17 | "test": "jest", 18 | "test:watch": "jest --watch", 19 | "test:cov": "jest --coverage", 20 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 21 | "test:e2e": "jest --config ./test/jest-e2e.json" 22 | }, 23 | "dependencies": { 24 | "@nestjs/class-transformer": "^0.4.0", 25 | "@nestjs/common": "^9.0.0", 26 | "@nestjs/core": "^9.0.0", 27 | "@nestjs/jwt": "^9.0.0", 28 | "@nestjs/mapped-types": "*", 29 | "@nestjs/passport": "^9.0.0", 30 | "@nestjs/platform-express": "^9.0.0", 31 | "@nestjs/swagger": "^6.1.3", 32 | "class-transformer": "^0.5.1", 33 | "class-validator": "^0.13.2", 34 | "cookie-parser": "^1.4.6", 35 | "dotenv": "^16.0.3", 36 | "env-cmd": "^10.1.0", 37 | "ioredis": "^5.2.4", 38 | "mysql2": "^2.3.3", 39 | "passport": "^0.6.0", 40 | "passport-github2": "^0.1.12", 41 | "passport-jwt": "^4.0.0", 42 | "passport-local": "^1.0.0", 43 | "reflect-metadata": "^0.1.13", 44 | "rimraf": "^3.0.2", 45 | "rxjs": "^7.2.0", 46 | "typeorm": "^0.3.10" 47 | }, 48 | "devDependencies": { 49 | "@nestjs/cli": "^9.0.0", 50 | "@nestjs/schematics": "^9.0.0", 51 | "@nestjs/testing": "^9.0.0", 52 | "@nestjs/typeorm": "^9.0.1", 53 | "@types/cookie-parser": "^1.4.3", 54 | "@types/express": "^4.17.13", 55 | "@types/jest": "28.1.8", 56 | "@types/node": "^16.0.0", 57 | "@types/passport-jwt": "^3.0.7", 58 | "@types/passport-local": "^1.0.34", 59 | "@types/supertest": "^2.0.11", 60 | "@typescript-eslint/eslint-plugin": "^5.0.0", 61 | "@typescript-eslint/parser": "^5.0.0", 62 | "eslint": "^8.0.1", 63 | "eslint-config-prettier": "^8.3.0", 64 | "eslint-plugin-prettier": "^4.0.0", 65 | "jest": "28.1.3", 66 | "prettier": "^2.3.2", 67 | "source-map-support": "^0.5.20", 68 | "supertest": "^6.3.1", 69 | "ts-jest": "28.0.8", 70 | "ts-loader": "^9.2.3", 71 | "ts-node": "^10.0.0", 72 | "tsconfig-paths": "4.1.0", 73 | "typescript": "^4.7.4" 74 | }, 75 | "jest": { 76 | "moduleFileExtensions": [ 77 | "js", 78 | "json", 79 | "ts" 80 | ], 81 | "rootDir": "src", 82 | "testRegex": ".*\\.spec\\.ts$", 83 | "transform": { 84 | "^.+\\.(t|j)s$": "ts-jest" 85 | }, 86 | "collectCoverageFrom": [ 87 | "**/*.(t|j)s" 88 | ], 89 | "coverageDirectory": "../coverage", 90 | "testEnvironment": "node" 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /backend/api/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | 4 | @Controller() 5 | export class AppController { 6 | constructor(private readonly appService: AppService) {} 7 | 8 | @Get() 9 | getHello(): string { 10 | return this.appService.getHello(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /backend/api/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { AppController } from './app.controller'; 4 | import { AppService } from './app.service'; 5 | import { UserModule } from './user/user.module'; 6 | import { DocumentModule } from './document/document.module'; 7 | import { BookmarkModule } from './bookmark/bookmark.module'; 8 | import { UserdocumentModule } from './userdocument/userdocument.module'; 9 | import { AuthModule } from './auth/auth.module'; 10 | import * as dotenv from 'dotenv'; 11 | import { LoggerOptions } from 'typeorm'; 12 | 13 | dotenv.config(); 14 | 15 | @Module({ 16 | imports: [ 17 | TypeOrmModule.forRoot({ 18 | type: 'mysql', 19 | host: process.env.DB_HOST, 20 | port: +process.env.DB_PORT, 21 | username: process.env.DB_USERNAME, 22 | password: process.env.DB_PASSWORD, 23 | database: process.env.DB_DATABASE, 24 | entities: [__dirname + '/**/*.entity{.ts,.js}'], 25 | synchronize: true, 26 | logging: process.env.DB_LOGGEROPTION as LoggerOptions 27 | }), 28 | UserModule, 29 | DocumentModule, 30 | BookmarkModule, 31 | UserdocumentModule, 32 | AuthModule 33 | ], 34 | controllers: [AppController], 35 | providers: [AppService] 36 | }) 37 | export class AppModule {} 38 | -------------------------------------------------------------------------------- /backend/api/src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class AppService { 5 | getHello(): string { 6 | return 'Hello World!'; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /backend/api/src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Post, Query, Redirect, Request, Res, UseGuards } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | import { Response } from 'express'; 4 | import { UserResponseDTO } from 'src/user/dto/user-response.dto'; 5 | import { User } from 'src/user/user.entity'; 6 | import { AuthService } from './auth.service'; 7 | import { JwtAuthGuard } from './jwt-auth.guard'; 8 | 9 | @Controller('auth') 10 | export class AuthController { 11 | constructor(private authService: AuthService) {} 12 | 13 | @UseGuards(AuthGuard('local')) 14 | @Post('login') 15 | async login(@Request() req, @Res({ passthrough: true }) res: Response): Promise { 16 | return this.authService.login(req.user, res); 17 | } 18 | 19 | @UseGuards(AuthGuard('github')) 20 | @Get('callback') 21 | @Redirect(process.env.CLIENT_HOST + '/document/main', 301) 22 | async callback(@Request() req, @Res({ passthrough: true }) res: Response): Promise { 23 | return this.authService.login(req.user, res); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /backend/api/src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { UserModule } from '../user/user.module'; 3 | 4 | import { AuthService } from './auth.service'; 5 | import { AuthController } from './auth.controller'; 6 | import { PassportModule } from '@nestjs/passport'; 7 | import { LocalStrategy } from './local.strategy'; 8 | import { JwtModule } from '@nestjs/jwt'; 9 | import { GithubOAuthStrategy } from './github.strategy'; 10 | import { JwtStrategy } from './jwt.strategy'; 11 | 12 | @Module({ 13 | imports: [ 14 | UserModule, 15 | PassportModule, 16 | JwtModule.register({ 17 | secret: process.env.JWT_SECRET, 18 | signOptions: { expiresIn: '1000y' } 19 | }) 20 | ], 21 | providers: [AuthService, LocalStrategy, GithubOAuthStrategy, JwtStrategy], 22 | controllers: [AuthController] 23 | }) 24 | export class AuthModule {} 25 | -------------------------------------------------------------------------------- /backend/api/src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { JwtService } from '@nestjs/jwt'; 3 | import { Response } from 'express'; 4 | import { UserCreateDTO } from 'src/user/dto/user-create.dto'; 5 | import { UserResponseDTO } from 'src/user/dto/user-response.dto'; 6 | import { User } from 'src/user/user.entity'; 7 | import { UserService } from '../user/user.service'; 8 | 9 | @Injectable() 10 | export class AuthService { 11 | constructor(private readonly userService: UserService, private jwtService: JwtService) {} 12 | 13 | async validateUser(nodeId: string): Promise { 14 | const user = await this.userService.findOneByNodeId(nodeId); 15 | if (user) { 16 | const result = user; 17 | return result; 18 | } 19 | return null; 20 | } 21 | 22 | async login(user: User, res: Response): Promise { 23 | const { name, nodeId, profileURL } = user; 24 | console.log(nodeId); 25 | const entity = await this.userService.findOneByNodeId(nodeId); 26 | console.log(entity); 27 | if (!entity) { 28 | console.log('noentity'); 29 | console.log('abc: ', name, nodeId, profileURL); 30 | await this.userService.create(new UserCreateDTO(name, nodeId, profileURL)); 31 | } 32 | const payload = { name, nodeId }; 33 | const accessToken = this.jwtService.sign(payload); 34 | res.cookie('access_token', accessToken, { 35 | expires: new Date(Date.now() + 100 * 12 * 30 * 24 * 3600000), 36 | httpOnly: true, 37 | }); 38 | 39 | return entity; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /backend/api/src/auth/github.strategy.ts: -------------------------------------------------------------------------------- 1 | import { PassportStrategy } from '@nestjs/passport'; 2 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 3 | import { AuthService } from './auth.service'; 4 | import { Strategy } from 'passport-github2'; 5 | 6 | @Injectable() 7 | export class GithubOAuthStrategy extends PassportStrategy(Strategy, 'github') { 8 | constructor(private authService: AuthService) { 9 | super({ 10 | clientID: process.env.GITHUB_CLIENTID, 11 | clientSecret: process.env.GITHUB_SECRET, 12 | callbackURL: process.env.HOST + '/auth/callback' 13 | // https://github.com/login/oauth/authorize?client_id=eed0086cbb5575f05dd1&scope=user:email,read:user` 14 | }); 15 | } 16 | 17 | async validate(accessToken, refreshToken, profile, done): Promise { 18 | if (!accessToken) { 19 | return UnauthorizedException; 20 | } 21 | console.log(accessToken); 22 | console.log(profile); 23 | const { name, node_id, avatar_url } = profile._json; 24 | 25 | return { nodeId: node_id, name, profileURL: avatar_url }; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /backend/api/src/auth/jwt-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class JwtAuthGuard extends AuthGuard('jwt') {} 6 | -------------------------------------------------------------------------------- /backend/api/src/auth/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ExtractJwt, Strategy } from 'passport-jwt'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Injectable } from '@nestjs/common'; 4 | import { Request } from 'express'; 5 | 6 | @Injectable() 7 | export class JwtStrategy extends PassportStrategy(Strategy) { 8 | constructor() { 9 | super({ 10 | jwtFromRequest: cookieExtractor, 11 | ignoreExpiration: true, 12 | secretOrKey: process.env.JWT_SECRET 13 | }); 14 | } 15 | 16 | async validate(payload: any) { 17 | return { nodeId: payload.nodeId, name: payload.name }; 18 | } 19 | } 20 | const cookieExtractor = (req: Request) => { 21 | let token = null; 22 | if (req && req.cookies) token = req.cookies['access_token']; 23 | 24 | return token; 25 | }; 26 | -------------------------------------------------------------------------------- /backend/api/src/auth/local.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Strategy } from 'passport-local'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 4 | import { AuthService } from './auth.service'; 5 | 6 | @Injectable() 7 | export class LocalStrategy extends PassportStrategy(Strategy) { 8 | constructor(private authService: AuthService) { 9 | super({ usernameField: 'nodeId' }); 10 | } 11 | 12 | async validate(username: string): Promise { 13 | const user = await this.authService.validateUser(username); 14 | if (!user) { 15 | throw new UnauthorizedException(); 16 | } 17 | return user; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /backend/api/src/auth/optional-jwt-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class OptionalJwtAuthGuard extends AuthGuard('jwt') { 6 | handleRequest( 7 | err: any, 8 | user: any, 9 | info: any, 10 | context: ExecutionContext, 11 | status?: any 12 | ): TUser { 13 | return user; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /backend/api/src/bookmark/bookmark.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; 2 | import { ApiCreatedResponse, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; 3 | import { BookmarkService } from './bookmark.service'; 4 | import { BookmarkCreateDTO } from './dto/bookmark-create.dto'; 5 | import { BookmarkResponseDTO } from './dto/bookmark-response.dto'; 6 | 7 | @ApiTags('Bookmark API') 8 | @Controller('bookmark') 9 | export class BookmarkController { 10 | constructor(private readonly bookmarkService: BookmarkService) {} 11 | 12 | @Get() 13 | @ApiOperation({ summary: '북마크 목록 API', description: '북마크 목록을 반환한다.' }) 14 | @ApiResponse({ description: '북마크 목록', type: [BookmarkResponseDTO] }) 15 | async list(): Promise { 16 | return this.bookmarkService.list(); 17 | } 18 | 19 | @Post() 20 | @ApiOperation({ summary: '북마크 생성 API', description: '북마크 생성' }) 21 | @ApiCreatedResponse({ description: '북마크 생성됨' }) 22 | create(@Body() bookmarkCreateDTO: BookmarkCreateDTO) { 23 | return this.bookmarkService.create(bookmarkCreateDTO); 24 | } 25 | 26 | @Get(':id') 27 | @ApiOperation({ summary: '북마크 정보 API', description: '해당 uuid 북마크 정보 얻기' }) 28 | @ApiCreatedResponse({ description: '북마크 정보', type: BookmarkResponseDTO }) 29 | findOne(@Param('id') id: string) { 30 | return this.bookmarkService.findOne(id); 31 | } 32 | 33 | @Delete(':id') 34 | @ApiOperation({ summary: '북마크 삭제 API', description: '해당 uuid 북마크 삭제하기' }) 35 | @ApiResponse({ description: '삭제됨' }) 36 | remove(@Param('id') id: string) { 37 | return this.bookmarkService.remove(id); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /backend/api/src/bookmark/bookmark.entity.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../user/user.entity'; 2 | import { Document } from '../document/document.entity'; 3 | import { 4 | Entity, 5 | PrimaryGeneratedColumn, 6 | CreateDateColumn, 7 | ManyToOne, 8 | JoinColumn, 9 | Unique 10 | } from 'typeorm'; 11 | 12 | @Entity() 13 | @Unique(['user', 'document']) 14 | export class Bookmark { 15 | @PrimaryGeneratedColumn('uuid') 16 | id: string; 17 | 18 | @ManyToOne(() => User) 19 | @JoinColumn({ name: 'user_id' }) 20 | user: User; 21 | 22 | @ManyToOne(() => Document) 23 | @JoinColumn({ name: 'document_id' }) 24 | document: Document; 25 | 26 | @CreateDateColumn({ name: 'created_at' }) 27 | createdAt: Date; 28 | } 29 | -------------------------------------------------------------------------------- /backend/api/src/bookmark/bookmark.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { BookmarkService } from './bookmark.service'; 3 | import { BookmarkController } from './bookmark.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { Bookmark } from './bookmark.entity'; 6 | 7 | @Module({ 8 | imports: [TypeOrmModule.forFeature([Bookmark])], 9 | providers: [BookmarkService], 10 | controllers: [BookmarkController] 11 | }) 12 | export class BookmarkModule {} 13 | -------------------------------------------------------------------------------- /backend/api/src/bookmark/bookmark.service.ts: -------------------------------------------------------------------------------- 1 | import { plainToClass } from '@nestjs/class-transformer'; 2 | import { Injectable } from '@nestjs/common'; 3 | import { InjectRepository } from '@nestjs/typeorm'; 4 | import { User } from 'src/user/user.entity'; 5 | import { Repository } from 'typeorm'; 6 | import { Bookmark } from './bookmark.entity'; 7 | import { BookmarkCreateDTO } from './dto/bookmark-create.dto'; 8 | import { BookmarkResponseDTO } from './dto/bookmark-response.dto'; 9 | import { Document } from 'src/document/document.entity'; 10 | 11 | @Injectable() 12 | export class BookmarkService { 13 | constructor( 14 | @InjectRepository(Bookmark) 15 | private boookmarkRepository: Repository 16 | ) {} 17 | async create(bookmarkCreateDTO: BookmarkCreateDTO) { 18 | const bookmark: Bookmark = this.boookmarkRepository.create({ ...bookmarkCreateDTO }); 19 | 20 | try { 21 | await this.boookmarkRepository.save(bookmark); 22 | } catch (e) { 23 | console.error(e.code); 24 | } 25 | } 26 | 27 | async list(): Promise { 28 | const bookmarks = await this.boookmarkRepository.find({ 29 | relations: ['user', 'document'], 30 | loadRelationIds: true 31 | }); 32 | return bookmarks.map((entity) => plainToClass(BookmarkResponseDTO, entity)); 33 | } 34 | 35 | findOne(id: string) { 36 | const entity = this.boookmarkRepository.find({ 37 | relations: ['user', 'document'], 38 | loadRelationIds: true, 39 | where: { id } 40 | }); 41 | return plainToClass(BookmarkResponseDTO, entity); 42 | } 43 | 44 | remove(id: string) { 45 | return this.boookmarkRepository.delete(id); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /backend/api/src/bookmark/dto/bookmark-create.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsUUID } from 'class-validator'; 3 | import { Document } from 'src/document/document.entity'; 4 | import { User } from 'src/user/user.entity'; 5 | 6 | export class BookmarkCreateDTO { 7 | @IsUUID() 8 | @ApiProperty() 9 | user!: User; 10 | 11 | @IsUUID() 12 | @ApiProperty() 13 | document!: Document; 14 | } 15 | -------------------------------------------------------------------------------- /backend/api/src/bookmark/dto/bookmark-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { Exclude, Expose } from '@nestjs/class-transformer'; 2 | import { User } from 'src/user/user.entity'; 3 | import { Document } from 'src/document/document.entity'; 4 | import { ApiProperty } from '@nestjs/swagger'; 5 | 6 | @Exclude() 7 | export class BookmarkResponseDTO { 8 | @ApiProperty() 9 | @Expose() 10 | id: string; 11 | 12 | @ApiProperty() 13 | @Expose() 14 | user: User; 15 | 16 | @ApiProperty() 17 | @Expose() 18 | document: Document; 19 | 20 | @ApiProperty() 21 | @Expose() 22 | createdAt: Date; 23 | } 24 | -------------------------------------------------------------------------------- /backend/api/src/document/document.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { DocumentController } from './document.controller'; 2 | import { DocumentService } from './document.service'; 3 | import { DocumentResponseDTO } from './dto/document-response.dto'; 4 | 5 | describe('DocumentController', () => { 6 | let documentController: DocumentController; 7 | let documentService: DocumentService; 8 | 9 | beforeEach(() => { 10 | documentService = new DocumentService(null, null, null, null); 11 | documentController = new DocumentController(documentService); 12 | }); 13 | 14 | describe('list', () => { 15 | it('should return an array of Document', async () => { 16 | const result = new DocumentResponseDTO(null); 17 | jest.spyOn(documentService, 'list').mockResolvedValue([result]); 18 | 19 | expect(await documentController.list()).toEqual([result]); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /backend/api/src/document/document.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common'; 2 | import { DocumentService } from './document.service'; 3 | import { DocumentResponseDTO } from './dto/document-response.dto'; 4 | import { DocumentCreateDTO } from './dto/document-create.dto'; 5 | import { DocumentUpdateDTO } from './dto/document-update.dto'; 6 | import { ApiTags, ApiOperation, ApiResponse, ApiCreatedResponse } from '@nestjs/swagger'; 7 | import { Document } from './document.entity'; 8 | import { DocumentDetailResponseDTO } from './dto/document-detail-response.dto'; 9 | import { OptionalJwtAuthGuard } from '../auth/optional-jwt-auth.guard'; 10 | import { Request } from 'express'; 11 | import { JwtAuthGuard } from '../auth/jwt-auth.guard'; 12 | 13 | @ApiTags('Document API') 14 | @Controller('document') 15 | export class DocumentController { 16 | constructor(private readonly documentService: DocumentService) {} 17 | 18 | @Get() 19 | @ApiOperation({ summary: '문서 목록 API', description: '문서 목록을 반환한다.' }) 20 | @ApiResponse({ description: '문서 목록', type: [DocumentResponseDTO] }) 21 | async list(): Promise { 22 | return this.documentService.list(); 23 | } 24 | 25 | @Post() 26 | @UseGuards(JwtAuthGuard) 27 | @ApiOperation({ summary: '문서 생성 API', description: '문서 생성' }) 28 | @ApiCreatedResponse({ description: '문서 생성됨' }) 29 | create(@Req() req, @Body() documentCreateDTO: DocumentCreateDTO): Promise { 30 | return this.documentService.create(documentCreateDTO, req.user); 31 | } 32 | 33 | @Get(':id') 34 | @UseGuards(OptionalJwtAuthGuard) 35 | @ApiOperation({ summary: '문서 정보 API', description: '해당 uuid 문서 정보 얻기' }) 36 | @ApiCreatedResponse({ description: '문서 정보', type: DocumentDetailResponseDTO }) 37 | async findOne(@Req() req: Request, @Param('id') id: string) { 38 | return this.documentService.findOne(id, req.user as { nodeId; name }); 39 | } 40 | 41 | @Post(':id/save-title') 42 | @ApiOperation({ summary: '문서 제목 저장 API', description: '해당 uuid 문서 제목 저장하기' }) 43 | @ApiResponse({ description: '저장됨' }) 44 | saveTitle(@Param('id') id: string, @Body() documentUpdateDTO: DocumentUpdateDTO) { 45 | return this.documentService.saveTitle(id, documentUpdateDTO); 46 | } 47 | 48 | @Post(':id/save-content') 49 | @ApiOperation({ summary: '문서 컨텐츠 저장 API', description: '해당 uuid 문서 컨텐츠 저장하기' }) 50 | @ApiResponse({ description: '저장됨' }) 51 | saveContent(@Param('id') id: string, @Body() documentUpdateDTO: DocumentUpdateDTO) { 52 | return this.documentService.insertContent(id, documentUpdateDTO); 53 | } 54 | 55 | @Post(':id/update-content') 56 | @ApiOperation({ summary: '문서 컨텐츠 저장 API', description: '해당 uuid 문서 컨텐츠 저장하기' }) 57 | @ApiResponse({ description: '저장됨' }) 58 | updateContent(@Param('id') id: string, @Body() documentUpdateDTO: DocumentUpdateDTO) { 59 | return this.documentService.updateContent(id, documentUpdateDTO); 60 | } 61 | 62 | @Delete(':id') 63 | @ApiOperation({ summary: '문서 삭제 API', description: '해당 uuid 문서 삭제하기' }) 64 | @ApiResponse({ description: '삭제됨' }) 65 | remove(@Param('id') id: string) { 66 | return this.documentService.remove(id); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /backend/api/src/document/document.entity.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../user/user.entity'; 2 | import { UserDocument } from '../userdocument/userdocument.entity'; 3 | import { 4 | Entity, 5 | Column, 6 | PrimaryGeneratedColumn, 7 | CreateDateColumn, 8 | DeleteDateColumn, 9 | ManyToOne, 10 | OneToMany 11 | } from 'typeorm'; 12 | 13 | @Entity() 14 | export class Document { 15 | @PrimaryGeneratedColumn('uuid') 16 | id: string; 17 | 18 | // @ManyToOne(() => User) 19 | // writer: User; 20 | 21 | @Column({ default: 'Untitled' }) 22 | title: string; 23 | 24 | @Column('text', { default: null }) 25 | content: string; 26 | 27 | @OneToMany(() => UserDocument, (userDocument) => userDocument.document, { 28 | onDelete: 'CASCADE', 29 | cascade: true 30 | }) 31 | userRelations: UserDocument[]; 32 | 33 | @CreateDateColumn({ name: 'created_at' }) 34 | createdAt: Date; 35 | 36 | @DeleteDateColumn({ name: 'deleted_at', default: null }) 37 | deletedAt: Date; 38 | 39 | addUserRelation(userDocument: UserDocument) { 40 | this.userRelations.push(userDocument); 41 | // userDocument.getUser().documentRelations.push(userDocument); 42 | userDocument.setDocument(this); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /backend/api/src/document/document.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { DocumentService } from './document.service'; 3 | import { DocumentController } from './document.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { Document } from './document.entity'; 6 | import { RedisModule } from 'src/redis/redis.module'; 7 | import { UserDocument } from 'src/userdocument/userdocument.entity'; 8 | import { User } from 'src/user/user.entity'; 9 | 10 | @Module({ 11 | imports: [TypeOrmModule.forFeature([Document, User, UserDocument]), RedisModule], 12 | providers: [DocumentService], 13 | controllers: [DocumentController] 14 | }) 15 | export class DocumentModule {} 16 | -------------------------------------------------------------------------------- /backend/api/src/document/dto/document-create.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsOptional, IsString } from 'class-validator'; 3 | 4 | export class DocumentCreateDTO { 5 | @ApiProperty() 6 | @IsString() 7 | @IsOptional() 8 | title: string; 9 | } 10 | -------------------------------------------------------------------------------- /backend/api/src/document/dto/document-detail-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { Exclude, Expose } from '@nestjs/class-transformer'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | @Exclude() 5 | export class DocumentDetailResponseDTO { 6 | @ApiProperty() 7 | @Expose() 8 | id: string; 9 | 10 | @ApiProperty() 11 | @Expose() 12 | title: string; 13 | 14 | @ApiProperty() 15 | @Expose() 16 | content: Record; 17 | 18 | @ApiProperty() 19 | @Expose() 20 | createdAt: Date; 21 | } 22 | -------------------------------------------------------------------------------- /backend/api/src/document/dto/document-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { Exclude, Expose } from '@nestjs/class-transformer'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { Document } from '../document.entity'; 4 | 5 | @Exclude() 6 | export class DocumentResponseDTO { 7 | constructor(document: Document) { 8 | this.id = document?.id; 9 | this.title = document?.title; 10 | this.createdAt = document?.createdAt; 11 | } 12 | 13 | @ApiProperty() 14 | @Expose() 15 | id: string; 16 | 17 | @ApiProperty() 18 | @Expose() 19 | title: string; 20 | 21 | @ApiProperty() 22 | @Expose() 23 | createdAt: Date; 24 | } 25 | -------------------------------------------------------------------------------- /backend/api/src/document/dto/document-update.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsOptional, IsString } from 'class-validator'; 3 | import { Char } from 'src/types/char'; 4 | 5 | export class DocumentUpdateDTO { 6 | @ApiProperty() 7 | @IsString() 8 | @IsOptional() 9 | title: string; 10 | 11 | @ApiProperty() 12 | // @IsString() 13 | @IsOptional() 14 | content: any; 15 | } 16 | -------------------------------------------------------------------------------- /backend/api/src/enum/role.enum.ts: -------------------------------------------------------------------------------- 1 | export enum UserRole { 2 | OWNER = 'owner', 3 | EDITOR = 'editor', 4 | VIEWER = 'viewer' 5 | } 6 | -------------------------------------------------------------------------------- /backend/api/src/filters/all-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common'; 2 | import { HttpAdapterHost } from '@nestjs/core'; 3 | 4 | @Catch() 5 | export class AllExceptionsFilter implements ExceptionFilter { 6 | constructor(private readonly httpAdapterHost: HttpAdapterHost) {} 7 | 8 | catch(exception: unknown, host: ArgumentsHost): void { 9 | // In certain situations `httpAdapter` might not be available in the 10 | // constructor method, thus we should resolve it here. 11 | const { httpAdapter } = this.httpAdapterHost; 12 | 13 | const ctx = host.switchToHttp(); 14 | 15 | const httpStatus = 16 | exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR; 17 | 18 | const responseBody = { 19 | statusCode: httpStatus, 20 | timestamp: new Date().toISOString(), 21 | path: httpAdapter.getRequestUrl(ctx.getRequest()) 22 | }; 23 | 24 | httpAdapter.reply(ctx.getResponse(), responseBody, httpStatus); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /backend/api/src/filters/http-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common'; 2 | import { Request, Response } from 'express'; 3 | 4 | @Catch(HttpException) 5 | export class HttpExceptionFilter implements ExceptionFilter { 6 | catch(exception: HttpException, host: ArgumentsHost) { 7 | const ctx = host.switchToHttp(); 8 | const response = ctx.getResponse(); 9 | const request = ctx.getRequest(); 10 | const status = exception.getStatus(); 11 | 12 | response.status(status).json({ 13 | statusCode: status, 14 | timestamp: new Date().toISOString(), 15 | path: request.url 16 | }); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /backend/api/src/main.ts: -------------------------------------------------------------------------------- 1 | import { HttpAdapterHost, NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; 4 | import { ValidationPipe } from './pipes/validation.pipe'; 5 | import * as cookieParser from 'cookie-parser'; 6 | import { json, urlencoded } from 'express'; 7 | import { HttpExceptionFilter } from './filters/http-exception.filter'; 8 | import { AllExceptionsFilter } from './filters/all-exception.filter'; 9 | 10 | async function bootstrap() { 11 | const app = await NestFactory.create(AppModule); 12 | const config = new DocumentBuilder() 13 | .setTitle('Codocs API Docs') 14 | .setDescription('Codocs API 문서') 15 | .setVersion('1.0') 16 | .build(); 17 | const document = SwaggerModule.createDocument(app, config); 18 | SwaggerModule.setup('api', app, document); 19 | 20 | app.useGlobalPipes(new ValidationPipe()); 21 | 22 | const adapterHost = app.get(HttpAdapterHost); 23 | // app.useGlobalFilters(new AllExceptionsFilter(adapterHost)); 24 | app.use(cookieParser()); 25 | app.use(json({ limit: '50mb' })); 26 | app.use(urlencoded({ limit: '50mb', extended: true })); 27 | app.enableCors({ origin: ['http://localhost:3000', 'http://codocs.site', 'http://www.codocs.site'], credentials: true }); 28 | 29 | await app.listen(8000); 30 | } 31 | bootstrap(); 32 | -------------------------------------------------------------------------------- /backend/api/src/pipes/validation.pipe.ts: -------------------------------------------------------------------------------- 1 | import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common'; 2 | import { validate } from 'class-validator'; 3 | import { plainToInstance } from 'class-transformer'; 4 | 5 | @Injectable() 6 | export class ValidationPipe implements PipeTransform { 7 | async transform(value: any, { metatype }: ArgumentMetadata) { 8 | if (!metatype || !this.toValidate(metatype)) { 9 | return value; 10 | } 11 | const object = plainToInstance(metatype, value); 12 | const errors = await validate(object); 13 | if (errors.length > 0) { 14 | throw new BadRequestException('Validation failed'); 15 | } 16 | return value; 17 | } 18 | 19 | private toValidate(metatype): boolean { 20 | const types = [String, Boolean, Number, Array, Object]; 21 | return !types.includes(metatype); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /backend/api/src/redis/redis.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import Redis from 'ioredis'; 3 | 4 | @Module({ 5 | providers: [ 6 | { 7 | provide: 'REDIS_OPTIONS', 8 | useValue: { 9 | host: process.env.DB_HOST, 10 | port: 6379 11 | } 12 | }, 13 | { 14 | inject: ['REDIS_OPTIONS'], 15 | provide: 'REDIS_CLIENT', 16 | useFactory: async ({ host, port }: { host: string, port: number }) => { 17 | const redis = await new Redis({ host, port }); 18 | return redis; 19 | } 20 | } 21 | ], 22 | exports: ['REDIS_CLIENT'] 23 | }) 24 | export class RedisModule {} 25 | -------------------------------------------------------------------------------- /backend/api/src/types/char.d.ts: -------------------------------------------------------------------------------- 1 | export interface Char { 2 | id: string; 3 | leftId: string; 4 | rightId: string; 5 | siteId: string; 6 | value: string; 7 | tombstone: boolean; 8 | } 9 | -------------------------------------------------------------------------------- /backend/api/src/user/dto/user-create.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsString, IsUrl } from 'class-validator'; 3 | 4 | export class UserCreateDTO { 5 | @ApiProperty() 6 | @IsString() 7 | name: string; 8 | 9 | @ApiProperty() 10 | nodeId: string; 11 | 12 | @ApiProperty() 13 | @IsUrl() 14 | profileURL: string; 15 | 16 | constructor(name: string, nodeId: string, profileURL: string) { 17 | this.name = name; 18 | this.nodeId = nodeId; 19 | this.profileURL = profileURL; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /backend/api/src/user/dto/user-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { Exclude, Expose } from '@nestjs/class-transformer'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | @Exclude() 5 | export class UserResponseDTO { 6 | @ApiProperty() 7 | @Expose() 8 | id: string; 9 | 10 | @ApiProperty() 11 | @Expose() 12 | name: string; 13 | 14 | @ApiProperty() 15 | @Expose() 16 | nodeId: string; 17 | 18 | @ApiProperty() 19 | @Expose() 20 | profileURL: string; 21 | } 22 | -------------------------------------------------------------------------------- /backend/api/src/user/dto/user-update.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiPropertyOptional } from '@nestjs/swagger'; 2 | import { IsOptional, IsString, IsUrl } from 'class-validator'; 3 | 4 | export class UserUpdateDTO { 5 | @IsString() 6 | @ApiPropertyOptional() 7 | @IsOptional() 8 | name: string; 9 | 10 | @IsUrl() 11 | @ApiPropertyOptional() 12 | @IsOptional() 13 | profileURL: string; 14 | } 15 | -------------------------------------------------------------------------------- /backend/api/src/user/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | Param, 7 | ParseUUIDPipe, 8 | Request, 9 | Patch, 10 | Post, 11 | UseGuards 12 | } from '@nestjs/common'; 13 | import { ApiCreatedResponse, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; 14 | import { JwtAuthGuard } from 'src/auth/jwt-auth.guard'; 15 | import { UserCreateDTO } from './dto/user-create.dto'; 16 | import { UserResponseDTO } from './dto/user-response.dto'; 17 | import { UserUpdateDTO } from './dto/user-update.dto'; 18 | import { User } from './user.entity'; 19 | import { UserService } from './user.service'; 20 | 21 | @ApiTags('User API') 22 | @Controller('user') 23 | export class UserController { 24 | constructor(private readonly userService: UserService) {} 25 | 26 | @Get() 27 | @ApiOperation({ summary: '유저 목록 API', description: '유저 목록 API (테스트용)' }) 28 | @ApiResponse({ description: '유저 목록', type: [UserResponseDTO] }) 29 | findAll(): Promise { 30 | return this.userService.findAll(); 31 | } 32 | 33 | @UseGuards(JwtAuthGuard) 34 | @Get('profile') 35 | @ApiOperation({ summary: '유저 정보 API', description: '유저 정보' }) 36 | @ApiCreatedResponse({ description: '유저 정보' }) 37 | getProfile(@Request() req): UserResponseDTO { 38 | return this.userService.findOneByNodeId(req.user.nodeId); 39 | } 40 | 41 | @Post() 42 | @ApiOperation({ summary: '유저 생성 API', description: '유저 회원가입' }) 43 | @ApiCreatedResponse({ description: '유저 생성됨' }) 44 | create(@Body() userCreateDTO: UserCreateDTO) { 45 | return this.userService.create(userCreateDTO); 46 | } 47 | 48 | @Get(':id') 49 | @ApiOperation({ summary: '유저 정보 API', description: '해당 uuid 유저 정보 얻기' }) 50 | @ApiCreatedResponse({ description: '유저 정보', type: UserResponseDTO }) 51 | findOne(@Param('id', ParseUUIDPipe) id: string): UserResponseDTO { 52 | return this.userService.findOne(id); 53 | } 54 | 55 | @Patch(':id') 56 | @ApiOperation({ summary: '유저 정보 변경 API', description: '해당 uuid 유저 정보 변경하기' }) 57 | @ApiResponse({ description: '변경됨' }) 58 | update(@Param('id', ParseUUIDPipe) id: string, @Body() userUpdateDTO: UserUpdateDTO) { 59 | return this.userService.update(id, userUpdateDTO); 60 | } 61 | 62 | @Delete(':id') 63 | @ApiOperation({ summary: '유저 삭제 API', description: '해당 uuid 유저 삭제하기' }) 64 | @ApiResponse({ description: '삭제됨' }) 65 | remove(@Param('id', ParseUUIDPipe) id: string) { 66 | return this.userService.remove(id); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /backend/api/src/user/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | Column, 4 | PrimaryGeneratedColumn, 5 | CreateDateColumn, 6 | OneToMany, 7 | Unique, 8 | DeleteDateColumn 9 | } from 'typeorm'; 10 | import { Bookmark } from '../bookmark/bookmark.entity'; 11 | import { UserDocument } from '../userdocument/userdocument.entity'; 12 | 13 | @Entity() 14 | @Unique(['nodeId']) 15 | export class User { 16 | @PrimaryGeneratedColumn('uuid') 17 | id: string; 18 | 19 | @Column() 20 | name: string; 21 | 22 | @Column({ name: 'node_id' }) 23 | nodeId: string; 24 | 25 | @Column({ name: 'profile_url' }) 26 | profileURL: string; 27 | 28 | // @OneToMany(() => Document, (document) => document.writer) 29 | // documents: Document[]; 30 | 31 | @OneToMany(() => Bookmark, (bookmark) => bookmark.user) 32 | bookmarks: Bookmark[]; 33 | 34 | @OneToMany(() => UserDocument, (userDocument) => userDocument.user) 35 | documentRelations: UserDocument[]; 36 | 37 | @CreateDateColumn({ name: 'created_at' }) 38 | createdAt: Date; 39 | 40 | @DeleteDateColumn({ name: 'deleted_at', default: null }) 41 | deletedAt: Date; 42 | } 43 | -------------------------------------------------------------------------------- /backend/api/src/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { UserController } from './user.controller'; 5 | import { User } from './user.entity'; 6 | import { UserService } from './user.service'; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([User])], 10 | controllers: [UserController], 11 | providers: [UserService], 12 | exports: [UserService] 13 | }) 14 | export class UserModule {} 15 | -------------------------------------------------------------------------------- /backend/api/src/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { plainToClass } from 'class-transformer'; 4 | import { Repository } from 'typeorm'; 5 | import { UserCreateDTO } from './dto/user-create.dto'; 6 | import { UserResponseDTO } from './dto/user-response.dto'; 7 | import { UserUpdateDTO } from './dto/user-update.dto'; 8 | 9 | import { User } from './user.entity'; 10 | 11 | @Injectable() 12 | export class UserService { 13 | constructor( 14 | @InjectRepository(User) 15 | private userRepository: Repository 16 | ) {} 17 | create(userCreateDTO: UserCreateDTO) { 18 | console.log(userCreateDTO); 19 | this.userRepository.save(userCreateDTO); 20 | } 21 | 22 | async findAll() { 23 | const users = await this.userRepository.find(); 24 | return users.map((entity) => plainToClass(UserResponseDTO, entity)); 25 | } 26 | 27 | findOne(id: string) { 28 | const entity = this.userRepository.findOneBy({ id }); 29 | return plainToClass(UserResponseDTO, entity); 30 | } 31 | 32 | findOneByNodeId(nodeId: string) { 33 | const entity = this.userRepository.findOneBy({ nodeId }); 34 | return plainToClass(UserResponseDTO, entity); 35 | } 36 | 37 | update(id: string, userUpdateDTO: UserUpdateDTO) { 38 | return this.userRepository.update(id, userUpdateDTO); 39 | } 40 | 41 | remove(id: string) { 42 | return this.userRepository.softDelete(id); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /backend/api/src/userdocument/dto/userdocument-create.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsEnum, IsUUID } from 'class-validator'; 3 | import { User } from 'src/user/user.entity'; 4 | import { UserRole } from '../../enum/role.enum'; 5 | import { Document } from 'src/document/document.entity'; 6 | 7 | export class UserDocumentCreateDTO { 8 | @ApiProperty() 9 | @IsUUID() 10 | document: Document; 11 | 12 | @ApiProperty() 13 | @IsUUID() 14 | user: User; 15 | 16 | @ApiProperty() 17 | @IsEnum(UserRole) 18 | role: UserRole; 19 | } 20 | -------------------------------------------------------------------------------- /backend/api/src/userdocument/dto/userdocument-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { Exclude, Expose } from '@nestjs/class-transformer'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { Document } from 'src/document/document.entity'; 4 | import { DocumentResponseDTO } from 'src/document/dto/document-response.dto'; 5 | import { User } from 'src/user/user.entity'; 6 | import { UserRole } from '../../enum/role.enum'; 7 | import { UserDocument } from '../userdocument.entity'; 8 | 9 | @Exclude() 10 | export class UserDocumentResponseDTO { 11 | constructor(userDocument: UserDocument) { 12 | const { document, lastVisited, role, createdAt, isBookmarked } = userDocument; 13 | this.id = document?.id; 14 | this.title = document?.title; 15 | this.createdAt = document?.createdAt; 16 | this.lastVisited = lastVisited; 17 | this.role = role; 18 | this.createdAt = createdAt; 19 | this.isBookmarked = isBookmarked; 20 | } 21 | 22 | @ApiProperty() 23 | @Expose() 24 | id: string; 25 | 26 | @ApiProperty() 27 | @Expose() 28 | title: string; 29 | 30 | @ApiProperty() 31 | @Expose() 32 | lastVisited: Date; 33 | 34 | @ApiProperty() 35 | @Expose() 36 | role: UserRole; 37 | 38 | @ApiProperty() 39 | @Expose() 40 | isBookmarked: boolean; 41 | 42 | @ApiProperty() 43 | @Expose() 44 | createdAt: Date; 45 | } 46 | -------------------------------------------------------------------------------- /backend/api/src/userdocument/dto/userdocument-update.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsDate, IsEnum, IsOptional, IsUUID } from 'class-validator'; 3 | import { UserRole } from '../../enum/role.enum'; 4 | 5 | export class UserDocumentUpdateDTO { 6 | @ApiProperty() 7 | @IsEnum(UserRole) 8 | @IsOptional() 9 | role: UserRole; 10 | 11 | @ApiProperty() 12 | @IsDate() 13 | @IsOptional() 14 | lastVisited: Date; 15 | } 16 | -------------------------------------------------------------------------------- /backend/api/src/userdocument/userdocument.entity.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../user/user.entity'; 2 | import { Document } from '../document/document.entity'; 3 | import { 4 | Entity, 5 | PrimaryGeneratedColumn, 6 | CreateDateColumn, 7 | ManyToOne, 8 | Column, 9 | JoinColumn, 10 | UpdateDateColumn, 11 | Unique, 12 | DeleteDateColumn 13 | } from 'typeorm'; 14 | import { UserRole } from '../enum/role.enum'; 15 | 16 | @Entity() 17 | @Unique(['user', 'document']) 18 | export class UserDocument { 19 | constructor(user, document, role = UserRole.EDITOR) { 20 | this.user = user; 21 | this.document = document; 22 | this.role = role; 23 | } 24 | 25 | @PrimaryGeneratedColumn('uuid') 26 | id: string; 27 | 28 | @ManyToOne(() => User) 29 | @JoinColumn({ name: 'user_id' }) 30 | user: User; 31 | 32 | @ManyToOne(() => Document) 33 | @JoinColumn({ name: 'document_id' }) 34 | document: Document; 35 | 36 | @CreateDateColumn({ name: 'created_at' }) 37 | createdAt: Date; 38 | 39 | @UpdateDateColumn({ name: 'last_visited' }) 40 | lastVisited: Date; 41 | 42 | @Column({ type: 'enum', enum: UserRole, default: UserRole.VIEWER }) 43 | role: UserRole; 44 | 45 | @DeleteDateColumn({ name: 'deleted_at', default: null }) 46 | deletedAt: Date; 47 | 48 | @Column({ name: 'is_bookmarked', default: false }) 49 | isBookmarked: boolean; 50 | 51 | setDocument(document: Document) { 52 | this.document = document; 53 | } 54 | 55 | getUser(): User { 56 | return this.user; 57 | } 58 | 59 | setLastVisitedNow() { 60 | this.lastVisited = new Date(); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /backend/api/src/userdocument/userdocument.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { UserDocumentService } from './userdocument.service'; 3 | import { UserdocumentController } from './userdocument.controller'; 4 | import { UserDocument } from './userdocument.entity'; 5 | import { TypeOrmModule } from '@nestjs/typeorm'; 6 | 7 | @Module({ 8 | imports: [TypeOrmModule.forFeature([UserDocument])], 9 | providers: [UserDocumentService], 10 | controllers: [UserdocumentController] 11 | }) 12 | export class UserdocumentModule {} 13 | -------------------------------------------------------------------------------- /backend/api/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule] 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()).get('/').expect(200).expect('Hello World!'); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /backend/api/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /backend/api/test/user.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import * as request from 'supertest'; 4 | import { UserModule } from '../src/user/user.module'; 5 | 6 | describe('Users', () => { 7 | let app: INestApplication; 8 | const userService = { findAll: () => ['test'] }; 9 | 10 | beforeAll(async () => { 11 | const moduleRef = await Test.createTestingModule({ 12 | imports: [UserModule] 13 | }) 14 | .overrideProvider(userService) 15 | .useValue(userService) 16 | .compile(); 17 | 18 | app = moduleRef.createNestApplication(); 19 | await app.init(); 20 | }); 21 | 22 | it(`/GET user`, () => { 23 | return request(app.getHttpServer()).get('/user').expect(200).expect({ 24 | data: userService.findAll() 25 | }); 26 | }); 27 | 28 | afterAll(async () => { 29 | await app.close(); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /backend/api/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /backend/api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /backend/compiler/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module' 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'], 10 | root: true, 11 | env: { 12 | node: true, 13 | jest: true 14 | }, 15 | ignorePatterns: ['.eslintrc.js'], 16 | rules: { 17 | '@typescript-eslint/interface-name-prefix': 'off', 18 | '@typescript-eslint/explicit-function-return-type': 'off', 19 | '@typescript-eslint/explicit-module-boundary-types': 'off', 20 | '@typescript-eslint/no-explicit-any': 'off', 21 | 'prettier/prettier': [ 22 | 'error', 23 | { 24 | endOfLine: 'auto' 25 | } 26 | ] 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /backend/compiler/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "trailingComma": "none", 4 | "tabWidth": 2, 5 | "semi": true, 6 | "singleQuote": true, 7 | "useTabs": false, 8 | "bracketSpacing": true, 9 | "bracketSameLine": true, 10 | "arrowParens": "always", 11 | "endOfLine": "auto", 12 | "parser": "typescript" 13 | } 14 | -------------------------------------------------------------------------------- /backend/compiler/index.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | 3 | const app: express.Application = express(); 4 | 5 | app.get('/', (req: express.Request, res: express.Response) => { 6 | res.send('🟢 Compiler server!'); 7 | }); 8 | 9 | app.set('port', 8200); 10 | 11 | app.listen(app.get('port'), () => console.log('Started server with ' + app.get('port'))); 12 | -------------------------------------------------------------------------------- /backend/compiler/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "compiler", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 0", 8 | "start:dev": "nodemon --exec ./node_modules/.bin/ts-node ./index.ts", 9 | "start": "ts-node ./index.ts" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC", 14 | "dependencies": { 15 | "express": "^4.18.2" 16 | }, 17 | "devDependencies": { 18 | "@types/express": "^4.17.14", 19 | "@types/node": "^18.11.9", 20 | "eslint": "^8.27.0", 21 | "nodemon": "^2.0.20", 22 | "prettier": "^2.7.1", 23 | "ts-node": "^10.9.1", 24 | "typescript": "^4.8.4" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /backend/compiler/src/index.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | 3 | const app: express.Application = express(); 4 | 5 | app.get('/', (req: express.Request, res: express.Response) => { 6 | res.send('🟢 Compiler server!'); 7 | }); 8 | 9 | app.set('port', 8200); 10 | 11 | app.listen(app.get('port'), () => console.log('Started server with ' + app.get('port'))); 12 | -------------------------------------------------------------------------------- /backend/compiler/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true, 4 | "lib": ["es5", "es6"], 5 | "target": "es5", 6 | "module": "commonjs", 7 | "moduleResolution": "node", 8 | "outDir": "./build", 9 | "emitDecoratorMetadata": true, 10 | "sourceMap": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /backend/socket/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module' 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'], 10 | root: true, 11 | env: { 12 | node: true, 13 | jest: true 14 | }, 15 | ignorePatterns: ['.eslintrc.js'], 16 | rules: { 17 | '@typescript-eslint/interface-name-prefix': 'off', 18 | '@typescript-eslint/explicit-function-return-type': 'off', 19 | '@typescript-eslint/explicit-module-boundary-types': 'off', 20 | '@typescript-eslint/no-explicit-any': 'off', 21 | 'prettier/prettier': [ 22 | 'error', 23 | { 24 | endOfLine: 'auto' 25 | } 26 | ] 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /backend/socket/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "trailingComma": "none", 4 | "tabWidth": 2, 5 | "semi": true, 6 | "singleQuote": true, 7 | "useTabs": false, 8 | "bracketSpacing": true, 9 | "bracketSameLine": true, 10 | "arrowParens": "always", 11 | "endOfLine": "auto", 12 | "parser": "typescript" 13 | } 14 | -------------------------------------------------------------------------------- /backend/socket/Dockerfile: -------------------------------------------------------------------------------- 1 | From node:16-alpine 2 | 3 | WORKDIR /app 4 | 5 | COPY package.json package-lock.json ./ 6 | 7 | COPY . ./ 8 | 9 | RUN npm ci 10 | 11 | ENTRYPOINT [ "/usr/local/bin/npm" , "start"] -------------------------------------------------------------------------------- /backend/socket/crdt-linear-ll/char.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from 'uuid'; 2 | 3 | class Char { 4 | id: string; 5 | 6 | leftId: string; 7 | 8 | rightId: string; 9 | 10 | siteId: string; 11 | 12 | value: string; 13 | 14 | tombstone: boolean; 15 | 16 | constructor(leftId: string, rightId: string, siteId: string, value: string, id: string = uuidv4()) { 17 | this.id = id; 18 | this.leftId = leftId; 19 | this.rightId = rightId; 20 | this.siteId = siteId; 21 | this.value = value; 22 | this.tombstone = false; 23 | } 24 | } 25 | 26 | export default Char; -------------------------------------------------------------------------------- /backend/socket/crdt-linear-ll/crdt.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from 'uuid'; 2 | import Char from './char'; 3 | 4 | interface CharMap { 5 | [key: string] : Char; 6 | } 7 | 8 | class CRDT { 9 | 10 | siteId: string; 11 | 12 | head: Char; 13 | 14 | tail: Char; 15 | 16 | charMap: CharMap; 17 | 18 | constructor() { 19 | this.siteId = uuidv4(); 20 | this.head = new Char('START', 'TAIL', this.siteId, '', 'HEAD'); 21 | this.tail = new Char('HEAD', 'END', this.siteId, '', 'TAIL'); 22 | this.head.tombstone = true; 23 | this.tail.tombstone = true; 24 | this.charMap = { 25 | [this.head.id] : this.head, 26 | [this.tail.id] : this.tail 27 | }; 28 | } 29 | 30 | saveInsert(chars: Char[]) { 31 | console.log("INSERT :" , chars); 32 | console.log("CHARMAP :" , this.charMap); 33 | const charsLen = chars.length; 34 | const [firstChar, lastChar] = [chars[0], chars[charsLen - 1]]; 35 | 36 | this.charMap[firstChar.leftId].rightId = firstChar.id; 37 | this.charMap[lastChar.rightId].leftId = lastChar.id; 38 | 39 | const charsObject = chars.reduce((acc, curr) => ({ 40 | ...acc, 41 | [curr.id] : curr 42 | }), {}); 43 | 44 | this.charMap = { 45 | ...this.charMap, 46 | ...charsObject 47 | }; 48 | } 49 | 50 | saveDelete(chars: Char[]) { 51 | let currentNode = this.head; 52 | let deleteStartIndex = 0; 53 | let currentIndex = 0; 54 | 55 | console.log("DELETE :" , chars); 56 | if(chars.length === 0){ 57 | return -1; 58 | } 59 | while (currentNode.rightId !== 'END') { 60 | if (chars[0]?.id === currentNode.id) { // 오류 61 | deleteStartIndex = currentIndex; 62 | break; 63 | } 64 | if (!currentNode.tombstone) { 65 | currentIndex++; 66 | } 67 | currentNode = this.charMap[currentNode.rightId]; 68 | } 69 | const deleteEndIndex = deleteStartIndex + chars.length; 70 | 71 | chars.forEach(char => { 72 | this.charMap[char.id].tombstone = true; 73 | }); 74 | 75 | 76 | return [deleteStartIndex, deleteEndIndex]; 77 | } 78 | 79 | toString (): string { 80 | let str = ''; 81 | let currentNode = this.head; 82 | while (currentNode.rightId !== 'END') { 83 | if (!currentNode.tombstone) { 84 | str += currentNode.value; 85 | } 86 | currentNode = this.charMap[currentNode.rightId]; 87 | } 88 | 89 | return str; 90 | } 91 | 92 | getAllNode (): Char[] { 93 | const nodeList = []; 94 | let currentNode = this.head; 95 | while (currentNode.rightId !== 'END') { 96 | nodeList.push(currentNode); 97 | currentNode = this.charMap[currentNode.rightId]; 98 | } 99 | 100 | return nodeList; 101 | } 102 | 103 | getCharMap() { 104 | return this.charMap; 105 | } 106 | } 107 | 108 | const crdt = new CRDT(); 109 | 110 | 111 | export { crdt, CRDT }; -------------------------------------------------------------------------------- /backend/socket/crdt-linear-server/char.ts: -------------------------------------------------------------------------------- 1 | export default class Char { 2 | index: CRDTIndex; 3 | 4 | siteId: string; 5 | 6 | value: string; 7 | 8 | constructor(index: CRDTIndex, siteId: string, value: string) { 9 | this.index = index; 10 | this.siteId = siteId; 11 | this.value = value; 12 | } 13 | } -------------------------------------------------------------------------------- /backend/socket/crdt-linear-server/crdt.ts: -------------------------------------------------------------------------------- 1 | import Char from './char'; 2 | import { v1 as uuidv1 } from 'uuid'; 3 | 4 | declare type CRDTIndex = number[]; 5 | class CRDT { 6 | siteId: string; 7 | 8 | struct: Char[]; 9 | 10 | constructor() { 11 | this.siteId = uuidv1(); 12 | // this.localCounter = 0; 13 | this.struct = []; 14 | } 15 | 16 | saveInsert(chars: Char[]) { 17 | // binary? 18 | const index = this.searchInsertIndex(chars[0]); 19 | this.struct.splice(index, 0, ...chars); 20 | } 21 | 22 | saveDeleteRange(chars: Char[]){ 23 | chars.forEach(char => this.saveDelete(char)); 24 | } 25 | 26 | saveDelete(char: Char) { 27 | const index = this.searchDeleteIndex(char); 28 | if (index === -1) { 29 | return; 30 | } 31 | 32 | this.struct.splice(index, 1); 33 | } 34 | 35 | searchDeleteIndex(char: Char) { 36 | return this.struct.findIndex( 37 | (c) => JSON.stringify(c.index) === JSON.stringify(char?.index) 38 | ); 39 | } 40 | 41 | searchInsertIndex(char: Char) { 42 | const index = this.struct.findIndex((c) => this.compareCRDTIndex(c.index, char.index)); 43 | return index === -1 ? this.struct.length : index; 44 | } 45 | 46 | compareCRDTIndex(originIndex: CRDTIndex, insertedIndex: CRDTIndex) : boolean { 47 | const insertedIndexLength = insertedIndex.length; 48 | const originIndexLength = originIndex.length; 49 | for (let i = 0; i < insertedIndexLength; i++) { 50 | if (originIndex[i] > insertedIndex[i]) { 51 | return true; 52 | } 53 | if (originIndex[i] < insertedIndex[i]) { 54 | return false; 55 | } 56 | if (originIndex[i] === undefined) { 57 | return false; 58 | } 59 | } 60 | if (originIndexLength === insertedIndexLength) { 61 | return false; 62 | } 63 | return true; 64 | } 65 | 66 | toString() { 67 | return this.struct.map((char) => char.value).join(''); 68 | } 69 | 70 | printStruct(){ 71 | return JSON.stringify(this.struct); 72 | } 73 | 74 | getStruct() { 75 | return [...this.struct]; 76 | } 77 | } 78 | 79 | const crdt = new CRDT(); 80 | 81 | export { crdt, CRDT }; -------------------------------------------------------------------------------- /backend/socket/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.ts", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 0", 8 | "start:dev": "env-cmd -f .env.development nodemon --exec ./node_modules/.bin/ts-node ./index.ts", 9 | "start": "env-cmd -f .env.production ts-node ./index.ts" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC", 14 | "dependencies": { 15 | "axios": "^1.2.1", 16 | "cors": "^2.8.5", 17 | "env-cmd": "^10.1.0", 18 | "express": "^4.18.2", 19 | "socket.io": "^4.5.4", 20 | "uuid": "^9.0.0" 21 | }, 22 | "devDependencies": { 23 | "@types/express": "^4.17.14", 24 | "@types/node": "^18.11.9", 25 | "@types/uuid": "^8.3.4", 26 | "eslint": "^8.27.0", 27 | "nodemon": "^2.0.20", 28 | "prettier": "^2.7.1", 29 | "ts-node": "^10.9.1", 30 | "typescript": "^4.8.4" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /backend/socket/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true, 4 | "lib": ["es5", "es6"], 5 | "target": "es5", 6 | "module": "commonjs", 7 | "moduleResolution": "node", 8 | "outDir": "./build", 9 | "emitDecoratorMetadata": true, 10 | "sourceMap": true, 11 | "downlevelIteration": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /backend/socket/types/crdt.d.ts: -------------------------------------------------------------------------------- 1 | declare type CRDTIndex = number[]; -------------------------------------------------------------------------------- /benchmark/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /benchmark/crdt-array/crdt/char.ts: -------------------------------------------------------------------------------- 1 | type CRDTIndex = number[]; 2 | 3 | export default class Char { 4 | index: CRDTIndex; 5 | 6 | siteId: string; 7 | 8 | value: string; 9 | 10 | constructor(index: CRDTIndex, siteId: string, value: string) { 11 | this.index = index; 12 | this.siteId = siteId; 13 | this.value = value; 14 | } 15 | } -------------------------------------------------------------------------------- /benchmark/crdt-array/localDeleteTest.ts: -------------------------------------------------------------------------------- 1 | import { CRDT } from "./crdt/crdt"; 2 | 3 | interface Template { 4 | [key: string] : string 5 | } 6 | 7 | interface TestType { 8 | [key: string] : Function 9 | } 10 | 11 | const testType: TestType = { 12 | "DELETE" : (index: number, testFunc : any, funcParams: any[]) => { 13 | testFunc(...funcParams); 14 | }, 15 | } 16 | 17 | const calcTimeDiff = (testFunc : any, funcParams: any[], time: number, type : string) : string => { 18 | const startTime = performance.now(); 19 | for (let i = 0; i < time ; i++) { 20 | testType[type](i, testFunc, funcParams); 21 | } 22 | const endTime = performance.now(); 23 | return `${endTime - startTime}ms`; 24 | } 25 | 26 | const localDeleteBenchmark = (type: string) => { 27 | const template : Template = { 28 | '10' : "", 29 | '100' : "", 30 | '1000' : "", 31 | '10000' : "", 32 | }; 33 | 34 | let crdt; 35 | let operationTime; 36 | let caseLength = Object.keys(template).length; 37 | 38 | for (let i = 1; i <= caseLength; i++) { 39 | crdt = new CRDT(); 40 | operationTime = Math.pow(10, i); 41 | for(let j = 0; j < operationTime; j++) { 42 | crdt.localInsert(0, 'a'); 43 | } 44 | template[operationTime.toString()] = calcTimeDiff(crdt.localDelete.bind(crdt), [0, 1], operationTime, type); 45 | } 46 | 47 | console.table(template); 48 | } 49 | 50 | export { localDeleteBenchmark }; -------------------------------------------------------------------------------- /benchmark/crdt-array/localInsertRangeTest.ts: -------------------------------------------------------------------------------- 1 | import { CRDT } from "./crdt/crdt"; 2 | 3 | interface Template { 4 | [key: string] : string 5 | } 6 | 7 | interface TestType { 8 | [key: string] : Function 9 | } 10 | 11 | const testType: TestType = { 12 | "BEST" : (index: number, testFunc : any, funcParams: any[]) => { 13 | testFunc(index*funcParams[0].length, ...funcParams); 14 | }, 15 | "WORST" : (index: number, testFunc : any, funcParams: any[]) => { 16 | testFunc(0, ...funcParams); 17 | }, 18 | "RANDOM" : (index: number, testFunc : any, funcParams: any[]) => { 19 | testFunc(Math.floor(Math.random() * (index + 1)) * funcParams[0].length, ...funcParams); 20 | } 21 | } 22 | 23 | const calcTimeDiff = (testFunc : any, funcParams: any[], time: number, type : string) : string => { 24 | const startTime = performance.now(); 25 | for (let i = 0; i < time ; i++) { 26 | testType[type](i, testFunc, funcParams); 27 | } 28 | const endTime = performance.now(); 29 | return `${endTime - startTime}ms`; 30 | } 31 | 32 | const localInsertRangeBenchmark = (type : string) => { 33 | const template : Template = { 34 | '10' : "", 35 | '100' : "", 36 | '1000' : "", 37 | '10000' : "", 38 | }; 39 | 40 | let crdt; 41 | let operationTime; 42 | let caseLength = Object.keys(template).length; 43 | 44 | for (let i = 1; i <= caseLength; i++) { 45 | crdt = new CRDT(); 46 | operationTime = Math.pow(10, i); 47 | template[operationTime.toString()] = calcTimeDiff(crdt.localInsertRange.bind(crdt), ["012"], operationTime, type); 48 | } 49 | 50 | console.table(template); 51 | } 52 | 53 | export { localInsertRangeBenchmark }; -------------------------------------------------------------------------------- /benchmark/crdt-array/localInsertTest.ts: -------------------------------------------------------------------------------- 1 | import { CRDT } from "./crdt/crdt"; 2 | 3 | interface Template { 4 | [key: string] : string 5 | } 6 | 7 | interface TestType { 8 | [key: string] : Function 9 | } 10 | 11 | 12 | const testType: TestType = { 13 | "BEST" : (index: number, testFunc : any, funcParams: any[]) => { 14 | testFunc(index, ...funcParams); 15 | }, 16 | "WORST" : (index: number, testFunc : any, funcParams: any[]) => { 17 | testFunc(0, ...funcParams); 18 | }, 19 | "RANDOM" : (index: number, testFunc : any, funcParams: any[]) => { 20 | testFunc(Math.floor(Math.random() * (index + 1)), ...funcParams); 21 | } 22 | } 23 | 24 | const calcTimeDiff = (testFunc : any, funcParams: any[], time: number, type : string) : string => { 25 | const startTime = performance.now(); 26 | for (let i = 0; i < time ; i++) { 27 | testType[type](i, testFunc, funcParams); 28 | } 29 | const endTime = performance.now(); 30 | return `${endTime - startTime}ms`; 31 | } 32 | 33 | const localInsertBenchmark = (type : string) => { 34 | const template : Template = { 35 | '10' : "", 36 | '100' : "", 37 | '1000' : "", 38 | '10000' : "", 39 | }; 40 | 41 | let crdt; 42 | let operationTime; 43 | let caseLength = Object.keys(template).length; 44 | 45 | for (let i = 1; i <= caseLength; i++) { 46 | crdt = new CRDT(); 47 | operationTime = Math.pow(10, i); 48 | template[operationTime.toString()] = calcTimeDiff(crdt.localInsert.bind(crdt), ["a"], operationTime, type); 49 | } 50 | 51 | console.table(template); 52 | } 53 | 54 | export { localInsertBenchmark }; 55 | -------------------------------------------------------------------------------- /benchmark/crdt-array/main.ts: -------------------------------------------------------------------------------- 1 | import { localInsertBenchmark } from "./localInsertTest"; 2 | import { localInsertRangeBenchmark } from "./localInsertRangeTest"; 3 | import { localDeleteBenchmark } from './localDeleteTest'; 4 | import { remoteInsertBenchmark } from './remoteInsertTest'; 5 | import { remoteInsertRangeBenchmark } from './remoteInsertRangeTest'; 6 | import { remoteDeleteBenchmark } from './remoteDeleteTest'; 7 | import { remoteDeleteRangeBenchmark } from './remoteDeleteRangeTest'; 8 | 9 | console.log("**1. LocalInsert 벤치마크**"); 10 | console.log("1-1) 모든 입력이 Best하게 들어갈 경우"); 11 | localInsertBenchmark("BEST"); 12 | 13 | console.log("1-2) 모든 입력이 Worst하게 들어갈 경우"); 14 | localInsertBenchmark("WORST"); 15 | 16 | console.log("1-3) 모든 입력이 랜덤하게 들어갈 경우"); 17 | localInsertBenchmark("RANDOM"); 18 | 19 | console.log("**2. LocalInsertRange 벤치마크**"); 20 | console.log("2-1) 모든 입력(3개를 한번에 입력)이 Best하게 들어갈 경우"); 21 | localInsertRangeBenchmark("BEST"); 22 | 23 | console.log("2-2) 모든 입력(3개를 한번에 입력)이 Worst하게 들어갈 경우"); 24 | localInsertRangeBenchmark("WORST"); 25 | 26 | console.log("2-3) 모든 입력(3개를 한번에 입력)이 랜덤하게 들어갈 경우"); 27 | localInsertRangeBenchmark("RANDOM"); 28 | 29 | console.log("**3. LocalDelete 벤치마크**"); 30 | console.log("3-1) 하나씩 삭제할 경우"); 31 | console.log("* 여러 개 삭제하는 경우는 하나씩 삭제하는 경우와 동작이 같아 테스트하지 않는다."); 32 | console.log("* index는 존재하는 문자의 갯수와도 같음"); 33 | localDeleteBenchmark("DELETE"); 34 | 35 | console.log("**4. remoteInsert 벤치마크**"); 36 | console.log("* 현재 양 쪽 모두 OperationCount만큼 인덱스가 존재하고 있음"); 37 | console.log("4-1) 모든 remote 요청이 Best하게 들어갈 경우"); 38 | remoteInsertBenchmark("BEST"); 39 | 40 | console.log("4-2) 모든 remote 요청이 문서 중앙에 들어갈 경우"); 41 | remoteInsertBenchmark("WORST"); 42 | 43 | console.log("4-3) 모든 remote 요청이 랜덤하게 들어갈 경우 (Worst)"); 44 | remoteInsertBenchmark("RANDOM"); 45 | 46 | console.log("**5. remoteInsertRange 벤치마크**"); 47 | console.log("5-1) 모든 입력(3개를 한번에 입력)이 Best하게 들어갈 경우"); 48 | remoteInsertRangeBenchmark("BEST"); 49 | 50 | console.log("5-2) 모든 입력(3개를 한번에 입력)이 문서 중앙에 들어갈 경우"); 51 | remoteInsertRangeBenchmark("WORST"); 52 | 53 | console.log("5-3) 모든 입력(3개를 한번에 입력)이 랜덤하게 들어갈 경우 (Worst)"); 54 | remoteInsertRangeBenchmark("RANDOM"); 55 | 56 | console.log("**6. remoteDelete 벤치마크**"); 57 | console.log("6-1) 한 글자를 삭제하는 요청이 들어올 경우"); 58 | remoteDeleteBenchmark("DELETE"); 59 | 60 | console.log("**7. remoteDeleteRange 벤치마크**"); 61 | console.log("7-1) index만큼의 문자를 한번에 삭제하는 요청이 들어올 경우"); 62 | remoteDeleteRangeBenchmark("DELETE"); 63 | -------------------------------------------------------------------------------- /benchmark/crdt-array/remoteDeleteRangeTest.ts: -------------------------------------------------------------------------------- 1 | import { CRDT } from "./crdt/crdt"; 2 | 3 | interface Template { 4 | [key: string] : string 5 | } 6 | 7 | interface TestType { 8 | [key: string] : Function 9 | } 10 | 11 | const testType: TestType = { 12 | "DELETE" : (index: number, testFunc : any, funcParams: any[]) => { 13 | testFunc(funcParams); 14 | }, 15 | } 16 | 17 | const calcTimeDiff = (testFunc : any, funcParams: any, time: number, type : string) : string => { 18 | const startTime = performance.now(); 19 | testType[type](0, testFunc, funcParams); 20 | const endTime = performance.now(); 21 | return `${endTime - startTime}ms`; 22 | } 23 | 24 | const remoteDeleteRangeBenchmark = (type: string) => { 25 | const template : Template = { 26 | '10' : "", 27 | '100' : "", 28 | '1000' : "", 29 | '10000' : "", 30 | }; 31 | 32 | let crdt; 33 | let operationTime; 34 | let caseLength = Object.keys(template).length; 35 | 36 | for (let i = 1; i <= caseLength; i++) { 37 | crdt = new CRDT(); 38 | const crdtOthers = new CRDT(); 39 | operationTime = Math.pow(10, i); 40 | for(let j = 0; j < operationTime; j++) { 41 | crdt.localInsert(0, 'a'); 42 | crdtOthers.localInsert(0, 'a'); 43 | } 44 | let operationHistory = crdtOthers.localDelete(0, operationTime); 45 | 46 | template[operationTime.toString()] = calcTimeDiff(crdt.remoteDeleteRange.bind(crdt), operationHistory, operationTime, type); 47 | } 48 | 49 | console.table(template); 50 | } 51 | 52 | export { remoteDeleteRangeBenchmark }; -------------------------------------------------------------------------------- /benchmark/crdt-array/remoteDeleteTest.ts: -------------------------------------------------------------------------------- 1 | import { CRDT } from "./crdt/crdt"; 2 | 3 | interface Template { 4 | [key: string] : string 5 | } 6 | 7 | interface TestType { 8 | [key: string] : Function 9 | } 10 | 11 | const testType: TestType = { 12 | "DELETE" : (index: number, testFunc : any, funcParams: any[]) => { 13 | testFunc(funcParams); 14 | }, 15 | } 16 | 17 | const calcTimeDiff = (testFunc : any, funcParams: any, time: number, type : string) : string => { 18 | const startTime = performance.now(); 19 | for (let i = 0; i < time ; i++) { 20 | testType[type](i, testFunc, funcParams[i]); 21 | } 22 | const endTime = performance.now(); 23 | return `${endTime - startTime}ms`; 24 | } 25 | 26 | const remoteDeleteBenchmark = (type: string) => { 27 | const template : Template = { 28 | '10' : "", 29 | '100' : "", 30 | '1000' : "", 31 | '10000' : "", 32 | }; 33 | 34 | let crdt; 35 | let operationTime; 36 | let caseLength = Object.keys(template).length; 37 | 38 | for (let i = 1; i <= caseLength; i++) { 39 | crdt = new CRDT(); 40 | operationTime = Math.pow(10, i); 41 | let operationHistory = [] 42 | for(let j = 0; j < operationTime; j++) { 43 | operationHistory.push(crdt.localInsert(0, 'a')); 44 | } 45 | template[operationTime.toString()] = calcTimeDiff(crdt.remoteDelete.bind(crdt), operationHistory, operationTime, type); 46 | } 47 | 48 | console.table(template); 49 | } 50 | 51 | export { remoteDeleteBenchmark }; -------------------------------------------------------------------------------- /benchmark/crdt-array/remoteInsertRangeTest.ts: -------------------------------------------------------------------------------- 1 | import { CRDT } from "./crdt/crdt"; 2 | 3 | interface Template { 4 | [key: string] : string 5 | } 6 | 7 | interface TestType { 8 | [key: string] : Function 9 | } 10 | 11 | const testType: TestType = { 12 | "BEST" : (index: number, testFunc : any, funcParams: any) => { 13 | testFunc(funcParams); 14 | }, 15 | "WORST" : (index: number, testFunc : any, funcParams: any) => { 16 | testFunc(funcParams); 17 | }, 18 | "RANDOM" : (index: number, testFunc : any, funcParams: any) => { 19 | testFunc(funcParams); 20 | } 21 | } 22 | 23 | const calcTimeDiff = (testFunc : any, funcParams: any[], time: number, type : string) : string => { 24 | const startTime = performance.now(); 25 | for (let i = 0; i < time; i++) { 26 | testType[type](i,testFunc, funcParams[0][i]); 27 | } 28 | const endTime = performance.now(); 29 | return `${endTime - startTime}ms`; 30 | } 31 | 32 | const remoteInsertRangeBenchmark = (type : string) => { 33 | const template : Template = { 34 | '10' : "", 35 | '100' : "", 36 | '1000' : "", 37 | // '10000' : "", 메모리 초과, 실행불가 38 | }; 39 | 40 | let crdt; 41 | let operationTime; 42 | let caseLength = Object.keys(template).length; 43 | 44 | for (let i = 1; i <= caseLength; i++) { 45 | crdt = new CRDT(); 46 | const crdtOthers = new CRDT(); 47 | const operationHistory = []; 48 | operationTime = Math.pow(10, i); 49 | 50 | for(let j = 0; j < operationTime; j++) { 51 | crdt.localInsert(0, 'a'); 52 | crdtOthers.localInsert(0, 'a'); 53 | } 54 | 55 | if (type === "BEST") { 56 | for(let k = 0; k < operationTime; k++) { 57 | operationHistory.push(crdtOthers.localInsertRange(0, 'abc')); 58 | } 59 | } else if (type === "WORST") { 60 | for(let k = 0; k < operationTime; k++) { 61 | operationHistory.push(crdtOthers.localInsertRange(Math.floor(operationTime/2), 'abc')); 62 | } 63 | 64 | } else if (type === "RANDOM") { 65 | for(let k = 0; k < operationTime; k++) { 66 | operationHistory.push(crdtOthers.localInsertRange(Math.floor(Math.random() * (operationTime + k + 1)), 'abc')); 67 | } 68 | } 69 | 70 | template[operationTime.toString()] = calcTimeDiff(crdt.remoteInsert.bind(crdt), [operationHistory], operationTime, type); 71 | } 72 | 73 | console.table(template); 74 | } 75 | 76 | export { remoteInsertRangeBenchmark }; 77 | -------------------------------------------------------------------------------- /benchmark/crdt-array/remoteInsertTest.ts: -------------------------------------------------------------------------------- 1 | import { CRDT } from "./crdt/crdt"; 2 | 3 | interface Template { 4 | [key: string] : string 5 | } 6 | 7 | interface TestType { 8 | [key: string] : Function 9 | } 10 | 11 | const testType: TestType = { 12 | "BEST" : (index: number, testFunc : any, funcParams: any[]) => { 13 | testFunc([funcParams]); 14 | }, 15 | "WORST" : (index: number, testFunc : any, funcParams: any[]) => { 16 | testFunc([funcParams]); 17 | }, 18 | "RANDOM" : (index: number, testFunc : any, funcParams: any[]) => { 19 | testFunc([funcParams]); 20 | } 21 | } 22 | 23 | const calcTimeDiff = (testFunc : any, funcParams: any[], time: number, type : string) : string => { 24 | const startTime = performance.now(); 25 | for (let i=0; i < time; i++) { 26 | testType[type](i,testFunc, funcParams[0][i]); 27 | } 28 | const endTime = performance.now(); 29 | return `${endTime - startTime}ms`; 30 | } 31 | 32 | const remoteInsertBenchmark = (type : string) => { 33 | const template : Template = { 34 | '10' : "", 35 | '100' : "", 36 | '1000' : "", 37 | '10000' : "", 38 | }; 39 | 40 | let crdt; 41 | let operationTime; 42 | let caseLength = Object.keys(template).length; 43 | 44 | for (let i = 1; i <= caseLength; i++) { 45 | crdt = new CRDT(); 46 | const crdtOthers = new CRDT(); 47 | const operationHistory = []; 48 | operationTime = Math.pow(10, i); 49 | 50 | for(let j = 0; j < operationTime; j++) { 51 | crdt.localInsert(0, 'a'); 52 | crdtOthers.localInsert(0, 'a'); 53 | } 54 | 55 | if (type === "BEST") { 56 | for(let k = 0; k < operationTime; k++) { 57 | operationHistory.push(crdtOthers.localInsert(0, 'a')); 58 | } 59 | } else if (type === "WORST") { 60 | for(let k = 0; k < operationTime; k++) { 61 | operationHistory.push(crdtOthers.localInsert(Math.floor(operationTime/2), 'a')); 62 | } 63 | 64 | } else if (type === "RANDOM") { 65 | for(let k = 0; k < operationTime; k++) { 66 | operationHistory.push(crdtOthers.localInsert(Math.floor(Math.random() * (operationTime + k + 1)), 'a')); 67 | } 68 | } 69 | 70 | template[operationTime.toString()] = calcTimeDiff(crdt.remoteInsert.bind(crdt), [operationHistory], operationTime, type); 71 | } 72 | 73 | console.table(template); 74 | } 75 | 76 | export { remoteInsertBenchmark }; 77 | -------------------------------------------------------------------------------- /benchmark/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "benchmark", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "dev": "nodemon --exec node --loader ts-node/esm main.ts" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "typescript": "^4.9.3", 15 | "uuid": "^9.0.0" 16 | }, 17 | "devDependencies": { 18 | "@types/uuid": "^9.0.0", 19 | "nodemon": "^2.0.20", 20 | "ts-node": "^10.9.1" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /conf.d/nginx.blue.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | 4 | location / { 5 | proxy_pass http://localhost:3001/; 6 | } 7 | location /api/ { 8 | proxy_pass http://localhost:8001/; 9 | } 10 | location /socket/ { 11 | proxy_pass http://localhost:8101/; 12 | } 13 | } -------------------------------------------------------------------------------- /conf.d/nginx.green.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | 4 | location / { 5 | proxy_pass http://localhost:3000/; 6 | } 7 | location /api/ { 8 | proxy_pass http://localhost:8000/; 9 | } 10 | location /socket/ { 11 | proxy_pass http://localhost:8100/; 12 | } 13 | } -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Blue 를 기준으로 현재 떠있는 컨테이너를 체크한다. 4 | EXIST_BLUE=$(docker-compose -p codocs-blue -f docker-compose.blue.yaml ps | grep Up) 5 | 6 | # 컨테이너 스위칭 7 | if [ -z "$EXIST_BLUE" ]; then 8 | echo "blue up" 9 | docker-compose -p codocs-blue -f docker-compose.blue.yaml up -d --build 10 | BEFORE_COMPOSE_COLOR="green" 11 | AFTER_COMPOSE_COLOR="blue" 12 | AFTER_API_PORT=8001 13 | AFTER_FRONTEND_PORT=3001 14 | AFTER_SOCKET_PORT=8101 15 | else 16 | echo "green up" 17 | docker-compose -p codocs-green -f docker-compose.green.yaml up -d --build 18 | BEFORE_COMPOSE_COLOR="blue" 19 | AFTER_COMPOSE_COLOR="green" 20 | AFTER_API_PORT=8000 21 | AFTER_FRONTEND_PORT=3000 22 | AFTER_SOCKET_PORT=8100 23 | fi 24 | 25 | 26 | # HTTP/1.1 27 | # curl -I http://localhost:3001 28 | # 새로운 컨테이너가 제대로 떴는지 확인 29 | # health checking 30 | echo "> Health check 시작합니다." 31 | 32 | for retry_count in {1..100} 33 | do 34 | response_api=$(curl -I -m 2 http://localhost:$AFTER_API_PORT) 35 | response_front=$(curl -I -m 2 http://localhost:$AFTER_FRONTEND_PORT) 36 | response_socket=$(curl -I -m 2 http://localhost:$AFTER_SOCKET_PORT) 37 | 38 | up_count1=$(echo $response_api | grep 'Keep-Alive' | wc -l) 39 | up_count2=$(echo $response_front | grep 'Keep-Alive' | wc -l) 40 | up_count3=$(echo $response_socket | grep 'Keep-Alive' | wc -l) 41 | 42 | echo $(($up_count1+$up_count2+$up_count3)) 43 | if [ $(($up_count1+$up_count2+$up_count3)) -ge 3 ] 44 | then 45 | echo "> Health check 성공" 46 | break 47 | else 48 | echo "> Health check: ${response_api}" 49 | echo "> Health check: ${response_frontend}" 50 | echo "> Health check: ${response_api}" 51 | echo "> Health check 연결 실패. 재시도..." 52 | sleep 1 53 | fi 54 | 55 | if [ $retry_count -eq 100 ] 56 | then 57 | echo "> Health check 실패. " 58 | echo "> Nginx에 연결하지 않고 배포를 종료합니다." 59 | docker-compose -p codocs-${AFTER_COMPOSE_COLOR} -f docker-compose.${AFTER_COMPOSE_COLOR}.yaml down 60 | exit 1 61 | fi 62 | 63 | done 64 | 65 | # nginx.config를 컨테이너에 맞게 변경해주고 reload 한다 66 | sudo cp ./conf.d/nginx.${AFTER_COMPOSE_COLOR}.conf /etc/nginx/conf.d/default.conf 67 | sudo systemctl restart nginx 68 | # docker-compose -f docker-compose.nginx.yaml restart 69 | # 이전 컨테이너 종료 70 | docker-compose -p codocs-${BEFORE_COMPOSE_COLOR} -f docker-compose.${BEFORE_COMPOSE_COLOR}.yaml down 71 | echo "$BEFORE_COMPOSE_COLOR down" 72 | -------------------------------------------------------------------------------- /docker-compose.blue.yaml: -------------------------------------------------------------------------------- 1 | version: '3.1' 2 | 3 | services: 4 | api: 5 | build: ./backend/api 6 | container_name: api-blue 7 | ports: 8 | - '8001:8000' 9 | extra_hosts: 10 | - "host.docker.internal:host-gateway" 11 | socket: 12 | build: ./backend/socket 13 | container_name: socket-blue 14 | ports: 15 | - '8101:8100' 16 | frontend: 17 | build: ./frontend 18 | container_name: frontend-blue 19 | ports: 20 | - '3001:3000' 21 | -------------------------------------------------------------------------------- /docker-compose.db.yaml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | 3 | services: 4 | db: 5 | image: mysql:latest 6 | container_name: mysql 7 | ports: 8 | - 3306:3306 9 | expose: 10 | - '3306' 11 | volumes: 12 | - /docker/mysql/data:/var/lib/mysql 13 | environment: 14 | MYSQL_DATABASE: 'db' 15 | MYSQL_ROOT_PASSWORD: "codocs*" 16 | cache: 17 | image: redis:6.2-alpine 18 | restart: always 19 | ports: 20 | - 6379:6379 21 | expose: 22 | - '6379' 23 | volumes: 24 | - /cache:/data 25 | -------------------------------------------------------------------------------- /docker-compose.green.yaml: -------------------------------------------------------------------------------- 1 | version: '3.1' 2 | 3 | services: 4 | api: 5 | build: ./backend/api 6 | container_name: api-green 7 | ports: 8 | - '8000:8000' 9 | extra_hosts: 10 | - "host.docker.internal:host-gateway" 11 | socket: 12 | build: ./backend/socket 13 | container_name: socket-green 14 | ports: 15 | - '8100:8100' 16 | frontend: 17 | build: ./frontend 18 | container_name: frontend-green 19 | ports: 20 | - '3000:3000' 21 | -------------------------------------------------------------------------------- /docker-compose.nginx.yaml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | 3 | services: 4 | web: 5 | image: nginx:1.14.2-alpine 6 | restart: always 7 | volumes: 8 | - ./public_html:/public_html 9 | - ./nginx/conf.d:/etc/nginx/conf.d/ 10 | - ./nginx/conf.d:/etc/nginx/ 11 | - ./dhparam:/etc/nginx/dhparam 12 | - ./certbot/conf/:/etc/nginx/ssl/ 13 | - ./certbot/data:/usr/share/nginx/html/letsencrypt 14 | ports: 15 | - 80:80 16 | - 443:443 17 | 18 | # certbot: 19 | # image: certbot/certbot:latest 20 | # command: certonly --webroot --webroot-path=/usr/share/nginx/html/letsencrypt --email rltjr1092@gmail.com --agree-tos --no-eff-email -d www.boostcamp.shop:19785 21 | # volumes: 22 | # - ./certbot/conf2/:/etc/letsencrypt 23 | # - ./certbot/logs2/:/var/log/letsencrypt 24 | # - ./certbot/data2:/usr/share/nginx/html/letsencrypt -------------------------------------------------------------------------------- /ecosystem.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps: [ 3 | { 4 | name: 'api', 5 | cwd: './backend/api', 6 | script: 'npm', 7 | args: 'start', 8 | }, 9 | { 10 | name: 'socket', 11 | cwd: './backend/socket', 12 | script: 'npm', 13 | args: 'start', 14 | }, 15 | { 16 | name: 'compiler', 17 | cwd: './backend/compiler', 18 | script: 'npm', 19 | args: 'start', 20 | }, 21 | { 22 | name: 'frontend', 23 | cwd: './frontend', 24 | script: 'npm', 25 | args: 'start', 26 | }, 27 | ], 28 | }; 29 | -------------------------------------------------------------------------------- /frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:react/recommended", 10 | "plugin:@typescript-eslint/recommended" 11 | ], 12 | "overrides": [], 13 | "parser": "@typescript-eslint/parser", 14 | "parserOptions": { 15 | "ecmaVersion": "latest", 16 | "sourceType": "module" 17 | }, 18 | "plugins": ["react", "@typescript-eslint"], 19 | "rules": { 20 | "quotes": ["error", "single"], 21 | "semi": ["error", "always"], 22 | "no-var": "error", 23 | "object-shorthand": ["error", "always"], 24 | "lines-between-class-members": ["error", "always"], 25 | "@typescript-eslint/no-var-requires": 0, 26 | "prefer-arrow-callback": "error", 27 | "prefer-destructuring": [ 28 | "error", 29 | { 30 | "array": true, 31 | "object": true 32 | }, 33 | { 34 | "enforceForRenamedProperties": false 35 | } 36 | ], 37 | "brace-style": "error", 38 | "curly": "error", 39 | "react/jsx-filename-extension": [2, { "extensions": [".js", ".jsx", ".ts", ".tsx"] }] 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /frontend/.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 | /out 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /frontend/.lighthouserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "ci": { 3 | "collect": { 4 | "staticDistDir": "./build", 5 | "url": ["http://localhost:3000"], 6 | "numberOfRuns": 5 7 | }, 8 | "upload": { 9 | "target": "filesystem", 10 | "outputDir": "./lhci_reports", 11 | "reportFilenamePattern": "%%PATHNAME%%-%%DATETIME%%-report.%%EXTENSION%%" 12 | }, 13 | "assert": { 14 | "assertions": { 15 | "first-contentful-paint": ["warn", { "minScore": 0.75 }], 16 | "largest-contentful-paint": ["warn", { "minScore": 0.75 }] 17 | } 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /frontend/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "trailingComma": "none", 4 | "tabWidth": 2, 5 | "semi": true, 6 | "singleQuote": true, 7 | "useTabs": false, 8 | "bracketSpacing": true, 9 | "bracketSameLine": true, 10 | "arrowParens": "always", 11 | "endOfLine": "auto", 12 | "parser": "typescript" 13 | } 14 | -------------------------------------------------------------------------------- /frontend/.template.env: -------------------------------------------------------------------------------- 1 | REACT_APP_GITHUB_OAUTH= 2 | REACT_APP_NODE_ENV= 3 | REACT_APP_DEV_URL= 4 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | From node:16-alpine 2 | 3 | WORKDIR /app 4 | 5 | COPY package.json package-lock.json ./ 6 | 7 | COPY . ./ 8 | 9 | RUN npm ci 10 | 11 | ENTRYPOINT [ "/usr/local/bin/npm" , "start"] -------------------------------------------------------------------------------- /frontend/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 | -------------------------------------------------------------------------------- /frontend/electron/main.ts: -------------------------------------------------------------------------------- 1 | const { app, BrowserWindow } = require('electron'); 2 | const path = require('path'); 3 | 4 | const createWindow = () => { 5 | const win = new BrowserWindow({ 6 | width: 1280, 7 | height: 720, 8 | webPreferences: { 9 | nodeIntegration: true, 10 | } 11 | }); 12 | 13 | app.isPackaged 14 | ? win.loadFile(`${path.join(__dirname, '../build/index.html')}`) 15 | : win.loadURL('http://localhost:3000'); 16 | }; 17 | 18 | app.whenReady().then(() => { 19 | createWindow(); 20 | 21 | app.on('activate', () => { 22 | if (BrowserWindow.getAllWindows().length === 0) { 23 | createWindow(); 24 | } 25 | }); 26 | }); 27 | 28 | app.on('window-all-closed', () => { 29 | if (process.platform !== 'darwin') { 30 | app.quit(); 31 | } 32 | }); -------------------------------------------------------------------------------- /frontend/forge.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | packagerConfig: { 3 | icon: './src/assets/codocs' 4 | }, 5 | rebuildConfig: {}, 6 | makers: [ 7 | { 8 | name: '@electron-forge/maker-squirrel', 9 | config: {}, 10 | }, 11 | { 12 | name: '@electron-forge/maker-zip', 13 | platforms: ['darwin'], 14 | config: { 15 | icon: './src/assets/codocs.icns', 16 | } 17 | }, 18 | { 19 | name: '@electron-forge/maker-deb', 20 | config: {}, 21 | }, 22 | { 23 | name: '@electron-forge/maker-rpm', 24 | config: {}, 25 | }, 26 | ], 27 | }; 28 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "codocs", 3 | "version": "1.0.0", 4 | "description": "Realtime Document Co-Editing Application", 5 | "homepage": ".", 6 | "main": "electron/main.ts", 7 | "author": "5-line-poem", 8 | "private": true, 9 | "scripts": { 10 | "start": "react-scripts start", 11 | "start:dev": "env-cmd -f .env.development react-scripts start", 12 | "build": "env-cmd -f .env.production react-scripts build", 13 | "test": "env-cmd -f .env.development react-scripts test", 14 | "eject": "react-scripts eject", 15 | "electron": "electron .", 16 | "electron:dev": "concurrently 'npm run start:dev' 'wait-on http://localhost:3000 && electron .'", 17 | "electron:forge": "electron-forge start", 18 | "package": "electron-forge package", 19 | "make": "electron-forge make" 20 | }, 21 | "dependencies": { 22 | "@testing-library/jest-dom": "^5.16.5", 23 | "@testing-library/react": "^13.4.0", 24 | "@testing-library/user-event": "^13.5.0", 25 | "@types/jest": "^27.5.2", 26 | "@types/node": "^16.18.3", 27 | "@types/react": "^18.0.25", 28 | "@types/react-dom": "^18.0.9", 29 | "@types/socket.io-client": "^3.0.0", 30 | "easymde": "^2.16.0", 31 | "electron-squirrel-startup": "^1.0.0", 32 | "env-cmd": "^10.1.0", 33 | "react": "^18.2.0", 34 | "react-dom": "^18.2.0", 35 | "react-query": "^3.39.2", 36 | "react-router-dom": "^6.4.3", 37 | "react-scripts": "5.0.1", 38 | "react-simplemde-editor": "^5.2.0", 39 | "recoil": "^0.7.6", 40 | "socket.io-client": "^4.5.4", 41 | "styled-components": "^5.3.6", 42 | "styled-normalize": "^8.0.7", 43 | "typescript": "^4.8.4", 44 | "uuid": "^9.0.0", 45 | "web-vitals": "^2.1.4" 46 | }, 47 | "eslintConfig": { 48 | "extends": [ 49 | "react-app", 50 | "react-app/jest" 51 | ] 52 | }, 53 | "browserslist": { 54 | "production": [ 55 | ">0.2%", 56 | "not dead", 57 | "not op_mini all" 58 | ], 59 | "development": [ 60 | "last 1 chrome version", 61 | "last 1 firefox version", 62 | "last 1 safari version" 63 | ] 64 | }, 65 | "devDependencies": { 66 | "@electron-forge/cli": "^6.0.5", 67 | "@electron-forge/maker-deb": "^6.0.5", 68 | "@electron-forge/maker-rpm": "^6.0.5", 69 | "@electron-forge/maker-squirrel": "^6.0.5", 70 | "@electron-forge/maker-zip": "^6.0.5", 71 | "@types/styled-components": "^5.1.26", 72 | "@types/uuid": "^8.3.4", 73 | "@typescript-eslint/eslint-plugin": "^5.43.0", 74 | "@typescript-eslint/parser": "^5.43.0", 75 | "concurrently": "^7.6.0", 76 | "electron": "^23.1.0", 77 | "eslint": "^8.27.0", 78 | "eslint-config-prettier": "^8.5.0", 79 | "eslint-plugin-prettier": "^4.2.1", 80 | "eslint-plugin-react": "^7.31.10", 81 | "msw": "^0.49.1", 82 | "prettier": "^2.7.1", 83 | "wait-on": "^7.0.1" 84 | }, 85 | "msw": { 86 | "workerDirectory": "public" 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /frontend/public/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web16-Codocs/2508e2ede509c00e7929d0afc6a285548df911b9/frontend/public/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /frontend/public/apple-touch-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web16-Codocs/2508e2ede509c00e7929d0afc6a285548df911b9/frontend/public/apple-touch-icon-57x57.png -------------------------------------------------------------------------------- /frontend/public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | Codocs 19 | 20 | 21 | 22 |
23 | 24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Codocs", 3 | "name": "Realtime Co-Document Editing Application", 4 | "icons": [ 5 | { 6 | "src": "favicon.svg", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/svg+xml" 9 | }, 10 | { 11 | "src": "apple-touch-icon-57x57.png", 12 | "sizes": "57x57", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "apple-touch-icon-180x180.png", 17 | "sizes": "180x180", 18 | "type": "image/png" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ThemeProvider } from 'styled-components'; 3 | import { QueryClient, QueryClientProvider } from 'react-query'; 4 | import { ReactQueryDevtools } from 'react-query/devtools'; 5 | import { RecoilRoot } from 'recoil'; 6 | import { ThemeSwitcher } from './components/themeSwitcher'; 7 | import { light, dark } from './theme'; 8 | import GlobalStyles from './GlobalStyles'; 9 | import Router from './Router'; 10 | import useDarkMode from './hooks/useDarkMode'; 11 | 12 | const { REACT_APP_NODE_ENV } = process.env; 13 | const queryClient = new QueryClient(); 14 | 15 | const App = () => { 16 | const {themeMode, toggleTheme} = useDarkMode(); 17 | 18 | return ( 19 | 20 | {REACT_APP_NODE_ENV === 'development' && } 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | ); 30 | }; 31 | 32 | export default App; 33 | -------------------------------------------------------------------------------- /frontend/src/GlobalStyles.ts: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from 'styled-components'; 2 | import normalize from 'styled-normalize'; 3 | 4 | const GlobalStyles = createGlobalStyle` 5 | ${normalize} 6 | 7 | * { 8 | padding: 0; 9 | margin: 0; 10 | box-sizing: border-box; 11 | transition: color, background-color 0.2s ease-out; 12 | } 13 | 14 | body { 15 | height: 100%; 16 | font-family: 'Inter', sans-serif; 17 | background-color: ${({ theme }) => theme.background}; 18 | } 19 | 20 | a { 21 | text-decoration: none; 22 | color: inherit; 23 | } 24 | 25 | input, textarea { 26 | -moz-user-select: auto; 27 | -webkit-user-select: auto; 28 | -ms-user-select: auto; 29 | user-select: auto; 30 | } 31 | 32 | input:focus { 33 | outline: none; 34 | } 35 | 36 | button { 37 | border: none; 38 | background: none; 39 | padding: 0; 40 | cursor: pointer; 41 | } 42 | 43 | .EasyMDEContainer { 44 | div, p { 45 | color: ${({ theme }) => theme.text}; 46 | background-color: 47 | } 48 | } 49 | 50 | .editor-preview { 51 | padding: 0 !important; 52 | background-color: ${({ theme }) => `${theme.background} !important`}; 53 | } 54 | 55 | .CodeMirror div.CodeMirror-cursor { 56 | border-left: 1px solid; 57 | border-color: ${({ theme }) => theme.text}; 58 | } 59 | `; 60 | 61 | export default GlobalStyles; 62 | -------------------------------------------------------------------------------- /frontend/src/Router.tsx: -------------------------------------------------------------------------------- 1 | import React, { lazy, Suspense } from 'react'; 2 | import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; 3 | import { Spinner } from './components/spinner'; 4 | import ToastPortal from './components/toastMsg/ToastPortal'; 5 | import MainLayout from './pages/MainLayout'; 6 | import NotFoundPage from './pages/NotFoundPage'; 7 | 8 | const LandingPage = lazy(() => import('./pages/LandingPage')); 9 | const MainPage = lazy(() => import('./pages/MainPage')); 10 | const PrivatePage = lazy(() => import('./pages/PrivatePage')); 11 | const SharedPage = lazy(() => import('./pages/SharedPage')); 12 | const BookmarkPage = lazy(() => import('./pages/BookmarkPage')); 13 | const DocumentPage = lazy(() => import('./pages/DocumentPage')); 14 | 15 | const Router = () => { 16 | return ( 17 | 18 | }> 19 | 20 | } /> 21 | }> 22 | } /> 23 | } /> 24 | } /> 25 | } /> 26 | 27 | } /> 28 | } />; 29 | } />; 30 | 31 | 32 | 33 | 34 | ); 35 | }; 36 | 37 | export default Router; 38 | -------------------------------------------------------------------------------- /frontend/src/assets/angle-down.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /frontend/src/assets/bookmark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/check.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/codocs.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web16-Codocs/2508e2ede509c00e7929d0afc6a285548df911b9/frontend/src/assets/codocs.icns -------------------------------------------------------------------------------- /frontend/src/assets/exclamation.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/github.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/house.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/lock.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/online.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/pencil.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/refly.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/together.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/trash.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/trashbag.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/atoms/onlineUserAtom.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'recoil'; 2 | 3 | const onlineUserState = atom({ 4 | key: 'onlineUser', 5 | default: [] 6 | }); 7 | 8 | export { onlineUserState }; 9 | -------------------------------------------------------------------------------- /frontend/src/atoms/toastMsgAtom.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'recoil'; 2 | 3 | const toastMsgState = atom({ 4 | key: 'toastMsg', 5 | default: { 6 | type: 'INIT', 7 | msg: '', 8 | key: 0, 9 | } 10 | }); 11 | 12 | export { toastMsgState }; 13 | -------------------------------------------------------------------------------- /frontend/src/components/docList/DocList.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen, waitFor } from '@testing-library/react'; 3 | import { BrowserRouter, Route, Routes } from 'react-router-dom'; 4 | import { DocList } from '../docList'; 5 | import { fetchDataFromPath } from '../../utils/fetchBeforeRender'; 6 | 7 | describe('render test for and ', () => { 8 | test('render ', async () => { 9 | // render( 10 | // 11 | // 12 | // } /> 13 | // 14 | // 15 | // ); 16 | 17 | // await waitFor(async () => { 18 | // const docListLinks = screen.getAllByRole('link'); 19 | // expect(docListLinks).toHaveLength(5); 20 | // }); 21 | }); 22 | }); -------------------------------------------------------------------------------- /frontend/src/components/docList/DocList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { DocListItem } from '../docListItem'; 4 | import useGetDocumentQuery from '../../query/document/useGetDocumentQuery'; 5 | import useDeleteDocumentMutation from '../../query/document/useDeleteDocumentMutation'; 6 | import useAddDocumentBookmarkMutation from '../../query/document/useAddDocumentBookmarkMutation'; 7 | import useRemoveDocumentBookmarkMutation from '../../query/document/useRemoveDocumentBookmarkMutation'; 8 | 9 | interface DocListProps { 10 | documentType: string; 11 | sortOption: string; 12 | } 13 | 14 | const DocList = ({ documentType, sortOption }: DocListProps) => { 15 | const { mutate: deleteMutate } = useDeleteDocumentMutation(documentType); 16 | const { mutate: bookmarkMutate } = useAddDocumentBookmarkMutation(documentType); 17 | const { mutate: unbookmarkMutate } = useRemoveDocumentBookmarkMutation(documentType); 18 | const { data: docList } = useGetDocumentQuery(documentType); 19 | 20 | const sortDocListByOption = (prev: DocListItem, next: DocListItem) => { 21 | if (sortOption === 'title') { 22 | return prev[sortOption] > next[sortOption] ? 1 : -1; 23 | } 24 | 25 | if (sortOption === 'createdAt') { 26 | return new Date(prev[sortOption]) > new Date(next[sortOption]) ? 1 : -1; 27 | } 28 | 29 | if (sortOption === 'lastVisited') { 30 | return new Date(prev[sortOption]) < new Date(next[sortOption]) ? 1 : -1; 31 | } 32 | }; 33 | 34 | return ( 35 | 36 | {docList?.sort(sortDocListByOption).map((doc: DocListItem) => 37 | ( 38 | 45 | ) 46 | )} 47 | 48 | ); 49 | }; 50 | 51 | const DocListWrapper = styled.section` 52 | display: grid; 53 | grid-template-columns: repeat(auto-fill, 240px); 54 | gap: 3rem 4.5rem; 55 | justify-content: space-evenly; 56 | margin-bottom: 2rem; 57 | `; 58 | 59 | export { DocList }; 60 | -------------------------------------------------------------------------------- /frontend/src/components/docList/index.ts: -------------------------------------------------------------------------------- 1 | export * from './DocList'; -------------------------------------------------------------------------------- /frontend/src/components/docListItem/index.ts: -------------------------------------------------------------------------------- 1 | export * from './DocListItem'; -------------------------------------------------------------------------------- /frontend/src/components/dropdown/Dropdown.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import styled, { useTheme } from 'styled-components'; 3 | import { DropdownTrigger } from './DropdownTrigger'; 4 | import { DropdownMenu } from './DropdownMenu'; 5 | import { DropdownItem } from './DropdownItem'; 6 | import { ReactComponent as AngleDownIcon } from '../../assets/angle-down.svg'; 7 | 8 | interface DropdownProps { 9 | value: string, 10 | onClick: React.Dispatch>, 11 | options: {[key:string] : string}, 12 | } 13 | 14 | const Dropdown = ({value, onClick, options}: DropdownProps) => { 15 | const [isOpened, setIsOpened] = useState(false); 16 | const theme = useTheme(); 17 | 18 | return ( 19 | 20 | } /> 26 | 27 | { 28 | Object.keys(options).map((option, index) => ( 29 | 34 | ) 35 | ) 36 | } 37 | 38 | 39 | ); 40 | }; 41 | 42 | const DropdownWrapper = styled.div` 43 | width: 140px; 44 | border-radius: 10px; 45 | background-color: ${({ theme }) => theme.reverseBackground}; 46 | `; 47 | 48 | export { Dropdown }; 49 | -------------------------------------------------------------------------------- /frontend/src/components/dropdown/DropdownItem.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | interface DropdownItemProps { 5 | text: string, 6 | value: string, 7 | onClick: React.Dispatch>, 8 | } 9 | 10 | const DropdownItem = ({text, value, onClick}: DropdownItemProps) => { 11 | 12 | const handleItemSelection = (e: React.MouseEvent) => { 13 | const target = e.target as HTMLLIElement; 14 | onClick(String(target.dataset['value'])); 15 | }; 16 | 17 | return ( 18 | 21 | {text} 22 | 23 | ); 24 | }; 25 | 26 | const Dropdown_Item = styled.li` 27 | width: 100%; 28 | display: flex; 29 | justify-content: space-between; 30 | align-items: center; 31 | cursor: pointer; 32 | padding: 1rem 0.75rem; 33 | border-bottom: 1px solid; 34 | border-color: ${({ theme }) => theme.reverseText}; 35 | color: ${({ theme }) => theme.reverseText}; 36 | background-color: ${({ theme }) => theme.reverseBackground}; 37 | `; 38 | 39 | export { DropdownItem }; -------------------------------------------------------------------------------- /frontend/src/components/dropdown/DropdownMenu.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | interface DropdownMenuProps { 5 | isOpened: boolean, 6 | children: ReactNode, 7 | } 8 | 9 | interface MenuProps { 10 | isOpened: boolean, 11 | } 12 | 13 | const DropdownMenu = ({isOpened, children}: DropdownMenuProps) => { 14 | return ( 15 | 16 | {children} 17 | 18 | ); 19 | }; 20 | 21 | const Dropdown_Menu = styled('ul')` 22 | width: 140px; 23 | list-style-type: none; 24 | position: absolute; 25 | padding: 0; 26 | margin: 0; 27 | background-color: ${({ theme }) => theme.background}; 28 | display: ${(props) => props.isOpened ? 'block' : 'none'}; 29 | 30 | li:first-child { 31 | border-top-left-radius: 10px; 32 | border-top-right-radius: 10px; 33 | } 34 | 35 | li:last-child { 36 | border-bottom-left-radius: 10px; 37 | border-bottom-right-radius: 10px; 38 | } 39 | `; 40 | 41 | export { DropdownMenu }; -------------------------------------------------------------------------------- /frontend/src/components/dropdown/DropdownTrigger.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | interface DropdownTriggerProps { 5 | value: string, 6 | onClick: React.Dispatch>, 7 | icon?: ReactNode; 8 | } 9 | 10 | const DropdownTrigger = ({value, onClick, icon}: DropdownTriggerProps) => { 11 | 12 | const handleOptionDisplay = (e:React.MouseEvent) => { 13 | e.preventDefault(); 14 | onClick((isOpened) => !isOpened); 15 | }; 16 | 17 | return ( 18 | 19 | {value && 20 | 21 | {value} 22 | 23 | } 24 | {icon} 25 | 26 | ); 27 | }; 28 | 29 | const Dropdown_Trigger = styled.button` 30 | width: 100%; 31 | display: flex; 32 | justify-content: space-between; 33 | align-items: center; 34 | padding: 1rem 0.75rem; 35 | border-radius: 10px; 36 | border-bottom: 1px solid; 37 | border-color: ${({ theme }) => theme.reverseText}; 38 | color: ${({ theme }) => theme.reverseText}; 39 | `; 40 | 41 | const SelectedOption = styled.span` 42 | white-space: pre-line; 43 | `; 44 | 45 | export { DropdownTrigger }; 46 | -------------------------------------------------------------------------------- /frontend/src/components/dropdown/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Dropdown'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/dropdownOption/DropdownOption.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | interface DropdownOptionProps { 5 | optionTitle: string; 6 | optionValue?: string; 7 | clickHandler?: React.MouseEventHandler; 8 | children?: ReactNode; 9 | } 10 | 11 | const DropdownOption = ({ 12 | optionTitle, 13 | optionValue, 14 | clickHandler, 15 | children: dropdownIcon 16 | }: DropdownOptionProps) => { 17 | return ( 18 | 21 | {optionTitle} 22 | {dropdownIcon} 23 | 24 | ); 25 | }; 26 | 27 | const DropdownOptionWrapper = styled.li` 28 | width: 100%; 29 | display: flex; 30 | justify-content: space-between; 31 | align-items: center; 32 | padding: 1rem 0.75rem; 33 | border-bottom: 1px solid; 34 | border-color: ${({ theme }) => theme.border}; 35 | color: ${({ theme }) => theme.reverseText}; 36 | `; 37 | 38 | const OptionTitle = styled.span` 39 | white-space: pre-line; 40 | `; 41 | 42 | export default DropdownOption; 43 | -------------------------------------------------------------------------------- /frontend/src/components/dropdownOption/index.ts: -------------------------------------------------------------------------------- 1 | export * from './DropdownOption'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/editor/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Editor'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/editorHeader/EditorHeader.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { useParams } from 'react-router-dom'; 4 | import useDocumentTitle from '../../hooks/useDocumentTitle'; 5 | import useToast from '../../hooks/useToast'; 6 | import { SiteLogo } from '../siteLogo'; 7 | import { OnlineUser } from '../onlineUser/OnlineUser'; 8 | import { devices } from '../../constants/breakpoints'; 9 | 10 | const EditorHeader = ({ fetchedTitle }: {fetchedTitle: string}) => { 11 | const { documentTitle, setDocumentTitle, updateDocumentTitle } = useDocumentTitle(fetchedTitle); 12 | const { document_id } = useParams(); 13 | const { alertToast } = useToast(); 14 | 15 | const handleCopyURL = () => { 16 | const document_URL = window.location.href; 17 | navigator.clipboard 18 | .writeText(document_URL) 19 | .then(() => alertToast('INFO', '링크를 복사했어요!')) 20 | .catch(() => alertToast('WARNING', '링크 복사에 실패했어요!')); 21 | }; 22 | 23 | const handleTitleChange = (e:React.ChangeEvent) => { 24 | setDocumentTitle(e.target.value); 25 | }; 26 | 27 | const handleTitleUpdate = () => { 28 | updateDocumentTitle(String(document_id)); 29 | }; 30 | 31 | return ( 32 | <> 33 | 34 | 35 | 41 | 42 | 43 | Share 44 | 45 | 46 | 47 | 48 | 49 | ); 50 | }; 51 | 52 | const HeaderContainer = styled.header` 53 | padding: 0.5rem 1rem; 54 | display: flex; 55 | justify-content: space-between; 56 | align-items: center; 57 | `; 58 | 59 | const DocumentTitle = styled.input` 60 | width: 12rem; 61 | font-weight: 200; 62 | font-size: 1.5rem; 63 | line-height: 1.75rem; 64 | text-align: center; 65 | border: none; 66 | color: ${({ theme }) => theme.text}; 67 | background-color: ${({ theme }) => theme.background}; 68 | 69 | :hover, :focus { 70 | border: 1px solid; 71 | border-color: ${({ theme }) => theme.primary}; 72 | } 73 | 74 | @media ${devices.mobile} { 75 | width: 10rem; 76 | margin-left: 2rem; 77 | } 78 | `; 79 | 80 | const RightButtonWrapper = styled.div` 81 | gap: 0.5rem; 82 | display: flex; 83 | justify-content: flex-end; 84 | align-items: center; 85 | `; 86 | 87 | const ShareButton = styled.button` 88 | font-weight: 500; 89 | font-size: 1rem; 90 | line-height: 1rem; 91 | border-radius: 10px; 92 | padding: 0.5rem 1.5rem; 93 | background: ${({ theme }) => theme.primary};; 94 | color: ${({ theme }) => theme.white}; 95 | 96 | @media ${devices.mobile} { 97 | font-weight: 400; 98 | padding: 0.5rem; 99 | } 100 | `; 101 | 102 | export { EditorHeader }; 103 | -------------------------------------------------------------------------------- /frontend/src/components/editorHeader/index.ts: -------------------------------------------------------------------------------- 1 | export * from './EditorHeader'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/header/Header.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense } from 'react'; 2 | import styled from 'styled-components'; 3 | import { SiteLogo } from '../siteLogo'; 4 | import { UserProfile } from '../userProfile'; 5 | import { Spinner } from '../spinner'; 6 | import usePageName from '../../hooks/usePageName'; 7 | 8 | const Header = () => { 9 | const { pageName } = usePageName(); 10 | 11 | return ( 12 | 13 | 14 | {pageName} 15 | }> 16 | 17 | 18 | 19 | ); 20 | }; 21 | 22 | const HeaderWrapper = styled.div` 23 | display: flex; 24 | justify-content: space-between; 25 | align-items: center; 26 | padding: 0.5rem 1rem; 27 | border-bottom: 1px solid; 28 | border-color: ${({ theme }) => theme.border}; 29 | `; 30 | 31 | const PageName = styled.span` 32 | font-size: 1rem; 33 | font-weight: 500; 34 | color: ${({ theme }) => theme.text}; 35 | `; 36 | 37 | export { Header }; 38 | -------------------------------------------------------------------------------- /frontend/src/components/header/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Header'; -------------------------------------------------------------------------------- /frontend/src/components/iconButton/IconButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | interface IconButtonProps { 5 | children: ReactNode, 6 | fill: string, 7 | width?: string, 8 | height?: string, 9 | hover?: string, 10 | active?: string, 11 | dataset?: string, 12 | clickHandler?: React.MouseEventHandler 13 | } 14 | 15 | const IconButton = (props: IconButtonProps) => { 16 | const {children, dataset, ...iconButtonStyles} = props; 17 | 18 | return ( 19 | 23 | {children} 24 | 25 | ); 26 | }; 27 | 28 | const IconButtonWrapper = styled.button` 29 | svg { 30 | width: ${(props) => (props.width || 1) + 'rem'}; 31 | height: ${(props) => (props.height || 1) + 'rem'}; 32 | fill: ${(props) => props.fill}; 33 | } 34 | &:hover { 35 | svg { 36 | fill: ${({hover, theme}) => hover || theme.interaction}; 37 | } 38 | } 39 | &.active { 40 | svg { 41 | fill: ${({active, theme}) => active || theme.interaction}; 42 | } 43 | } 44 | `; 45 | 46 | export { IconButton }; -------------------------------------------------------------------------------- /frontend/src/components/iconButton/index.ts: -------------------------------------------------------------------------------- 1 | export * from './IconButton'; -------------------------------------------------------------------------------- /frontend/src/components/loginButton/LoginButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import githubIcon from '../../assets/github.svg'; 4 | import { devices } from '../../constants/breakpoints'; 5 | 6 | const LoginButton = () => { 7 | const handleGithubOAuth = (e: React.MouseEvent) => { 8 | e.preventDefault(); 9 | window.location.href = process.env.REACT_APP_GITHUB_OAUTH as string; 10 | }; 11 | 12 | return ( 13 | 14 | Github 아이콘 15 | Github로 시작하기 16 | 17 | ); 18 | }; 19 | 20 | const GitHubButton = styled.button` 21 | display: flex; 22 | justify-content: center; 23 | align-items: center; 24 | gap: 1rem; 25 | 26 | padding: 1.25rem 1.5rem; 27 | border-radius: 1.25rem; 28 | background: ${({ theme }) => theme.primary};; 29 | 30 | img { 31 | width: 2.5rem; 32 | height: 2.5rem; 33 | } 34 | 35 | span { 36 | font-weight: 700; 37 | font-size: 1.5rem; 38 | line-height: 1.75rem; 39 | text-align: center; 40 | 41 | color: ${({ theme }) => theme.white}; 42 | } 43 | 44 | @media ${devices.mobile} { 45 | padding: 0.75rem 1rem; 46 | 47 | img { 48 | width: 2rem; 49 | height: 2rem; 50 | } 51 | 52 | span { 53 | font-weight: 500; 54 | font-size: 1rem; 55 | } 56 | } 57 | `; 58 | 59 | export { LoginButton }; 60 | -------------------------------------------------------------------------------- /frontend/src/components/loginButton/index.ts: -------------------------------------------------------------------------------- 1 | export * from './LoginButton'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/modal/Modal.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react'; 2 | import styled from 'styled-components'; 3 | import ModalPortal from './ModalPortal'; 4 | 5 | interface ModalProps { 6 | children: ReactNode, 7 | toggleModal: () => void, 8 | } 9 | 10 | const Modal = ({children, toggleModal}: ModalProps) => { 11 | 12 | return ( 13 | 14 | 15 | {children} 16 | 17 | 18 | ); 19 | }; 20 | 21 | const Dimmed = styled.div` 22 | position: fixed; 23 | width: 100%; 24 | height: 100%; 25 | top: 0; 26 | left: 0; 27 | background-color: rgba(30, 30, 30, 0.9); 28 | z-index: 5000; 29 | `; 30 | 31 | export { Modal }; 32 | -------------------------------------------------------------------------------- /frontend/src/components/modal/ModalPortal.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | const ModalPortal = ({children} : {children: ReactNode}) => { 5 | const modalRoot = document.getElementById('modal-root'); 6 | return ReactDOM.createPortal(children, modalRoot as HTMLElement); 7 | }; 8 | 9 | export default ModalPortal; -------------------------------------------------------------------------------- /frontend/src/components/modal/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Modal'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/modalForm/ModalForm.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled, { useTheme } from 'styled-components'; 3 | import MODAL_CONTENT from '../../constants/modalContent'; 4 | import { devices } from '../../constants/breakpoints'; 5 | 6 | interface ModalFormProps { 7 | type: string; 8 | actionHandler: () => void; 9 | cancelHandler: () => void; 10 | } 11 | 12 | interface AnswerBtnProps { 13 | backgroundColor: string; 14 | } 15 | 16 | const ModalForm = ({type, actionHandler, cancelHandler}: ModalFormProps) => { 17 | const theme = useTheme(); 18 | 19 | return ( 20 | 21 | 22 | {MODAL_CONTENT[type].title} 23 | {MODAL_CONTENT[type].description} 24 | 25 | 26 | 29 | 취소 30 | 31 | 34 | 확인 35 | 36 | 37 | 38 | ); 39 | }; 40 | 41 | const ModalFormWrapper = styled.div` 42 | display: flex; 43 | flex-direction: column; 44 | justify-content: space-evenly; 45 | width: 30rem; 46 | min-height: 18rem; 47 | border-radius: 10px; 48 | padding: 3rem 2rem; 49 | margin: 0 auto; 50 | margin-top: 6rem; 51 | background-color: ${({ theme }) => theme.gray}; 52 | 53 | @media ${devices.mobile} { 54 | width: 20rem; 55 | min-height: 16rem; 56 | padding: 2rem 1rem; 57 | margin: 0 auto; 58 | margin-top: 14rem; 59 | } 60 | `; 61 | 62 | const QuestionGroup = styled.div` 63 | display: flex; 64 | flex-direction: column; 65 | justify-content: space-between; 66 | align-items: center; 67 | margin: 2rem 0; 68 | color: ${({ theme }) => theme.text}; 69 | 70 | @media ${devices.mobile} { 71 | margin: 2rem 0.25rem; 72 | } 73 | `; 74 | 75 | const Title = styled.h2` 76 | font-size: 2rem; 77 | font-weight: 700; 78 | 79 | @media ${devices.mobile} { 80 | font-size: 1.5rem; 81 | font-weight: 500; 82 | } 83 | `; 84 | 85 | const Description = styled.p` 86 | word-break: break-all; 87 | margin-top: 1rem; 88 | 89 | @media ${devices.mobile} { 90 | max-width: 16rem; 91 | text-align: start; 92 | } 93 | `; 94 | 95 | const AnswerGroup = styled.div` 96 | display: flex; 97 | justify-content: space-between; 98 | margin: 1rem 1rem; 99 | `; 100 | 101 | const AnswerBtn = styled('button')` 102 | font-size: 1.5rem; 103 | border-radius: 10px; 104 | padding: 1rem 4rem; 105 | color: ${({ theme }) => theme.text}; 106 | background-color: ${(props) => props.backgroundColor}; 107 | 108 | @media ${devices.mobile} { 109 | font-size: 1rem; 110 | padding: 1rem 2.5rem; 111 | } 112 | `; 113 | 114 | export { ModalForm }; 115 | -------------------------------------------------------------------------------- /frontend/src/components/modalForm/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ModalForm'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/onlineUser/OnlineUser.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import styled, { useTheme } from 'styled-components'; 3 | import DropdownOption from '../dropdownOption/DropdownOption'; 4 | import { ReactComponent as TogetherIcon } from '../../assets/together.svg'; 5 | import { ReactComponent as OnlineIcon } from '../../assets/online.svg'; 6 | import { useRecoilState } from 'recoil'; 7 | import { onlineUserState } from '../../atoms/onlineUserAtom'; 8 | import { devices } from '../../constants/breakpoints'; 9 | 10 | interface OnlineUserState { 11 | id: string; 12 | name: string; 13 | color: string; 14 | } 15 | 16 | interface OptionOpenedProps { 17 | isOptionOpened: boolean; 18 | } 19 | 20 | const OnlineUser = () => { 21 | const [isOptionOpened, setIsOptionOpened] = useState(false); 22 | const [onlineUserInfo, setOnlineUserInfo] = useRecoilState(onlineUserState); 23 | const theme = useTheme(); 24 | 25 | const handleOpenOption = (e: React.MouseEvent) => { 26 | e.preventDefault(); 27 | setIsOptionOpened(() => !isOptionOpened); 28 | }; 29 | 30 | return ( 31 | 32 | 33 | 34 | 35 | {onlineUserInfo.length} 36 | 37 | 38 | 39 | {onlineUserInfo.map((person: OnlineUserState) => 40 | 44 | 45 | 46 | )} 47 | 48 | 49 | ); 50 | }; 51 | 52 | const OnlineUserWrapper = styled.div` 53 | z-index: 1000; 54 | cursor: pointer; 55 | `; 56 | 57 | const OnlineUserCount = styled.span` 58 | display: flex; 59 | align-items: center; 60 | `; 61 | 62 | const BtnNumber = styled.span` 63 | font-weight: 500; 64 | font-size: 1.5rem; 65 | margin-left: 0.5rem; 66 | color: ${({ theme }) => theme.text}; 67 | text-shadow: ${({ theme }) => `0px 4px 4px ${theme.defaultShadow}`}; 68 | 69 | @media ${devices.mobile} { 70 | display: none; 71 | } 72 | `; 73 | 74 | const OnlineUserList = styled('ul')` 75 | width: 140px; 76 | list-style-type: none; 77 | position: absolute; 78 | top: 3rem; 79 | right: 0; 80 | border-radius: 10px; 81 | padding: 0 0.25rem; 82 | margin: 0; 83 | background-color: ${({ theme }) => theme.reverseBackground}; 84 | display: ${(props) => (props.isOptionOpened ? 'block' : 'none')}; 85 | 86 | li:last-child { 87 | border: none; 88 | } 89 | `; 90 | 91 | export { OnlineUser }; 92 | -------------------------------------------------------------------------------- /frontend/src/components/onlineUser/index.ts: -------------------------------------------------------------------------------- 1 | export * from './OnlineUser'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/sideBar/SideBar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled, { useTheme } from 'styled-components'; 3 | import { NavLink } from 'react-router-dom'; 4 | import { ReactComponent as HouseIcon } from '../../assets/house.svg'; 5 | import { ReactComponent as LockIcon } from '../../assets/lock.svg'; 6 | import { ReactComponent as TogetherIcon } from '../../assets/together.svg'; 7 | import { ReactComponent as BookmarkIcon } from '../../assets/bookmark.svg'; 8 | import { IconButton } from '../iconButton'; 9 | import { devices } from '../../constants/breakpoints'; 10 | 11 | const SideBar = () => { 12 | const theme = useTheme(); 13 | 14 | const ICON_STYLES = { 15 | fill: theme.gray, 16 | width: '1.5', 17 | height: '1.5' 18 | }; 19 | 20 | return ( 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | ); 47 | }; 48 | 49 | const SideBarWrapper = styled.nav` 50 | position: sticky; 51 | top: 0; 52 | height: 100vh; 53 | padding: 1rem; 54 | display: flex; 55 | flex-direction: column; 56 | border-right: 1px solid; 57 | border-color: ${({ theme }) => theme.border}; 58 | 59 | @media ${devices.mobile} { 60 | position: static; 61 | height: auto; 62 | padding: 0; 63 | flex-direction: row; 64 | justify-content: space-evenly; 65 | border-right: none; 66 | border-bottom: 1px solid; 67 | border-color: ${({ theme }) => theme.border}; 68 | } 69 | `; 70 | 71 | const NavMenu = styled(NavLink)` 72 | margin: 1rem 0; 73 | &.active { 74 | svg { 75 | fill: ${({ theme }) => theme.interaction}; 76 | } 77 | } 78 | `; 79 | 80 | export { SideBar }; 81 | -------------------------------------------------------------------------------- /frontend/src/components/sideBar/index.ts: -------------------------------------------------------------------------------- 1 | export * from './SideBar'; -------------------------------------------------------------------------------- /frontend/src/components/siteLogo/SiteLogo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled, { useTheme } from 'styled-components'; 3 | import { NavLink } from 'react-router-dom'; 4 | import { ReactComponent as LogoIcon } from '../../assets/logo.svg'; 5 | import { devices } from '../../constants/breakpoints'; 6 | 7 | const SiteLogo = () => { 8 | const theme = useTheme(); 9 | 10 | return ( 11 | 12 | 13 | Codocs 14 | 15 | ); 16 | }; 17 | 18 | const LogoWrapper = styled(NavLink)` 19 | display: flex; 20 | align-items: center; 21 | `; 22 | 23 | const LogoTitle = styled.span` 24 | font-size: 1.5rem; 25 | font-weight: 700; 26 | padding: 0 0.5rem; 27 | color: ${({ theme }) => theme.text}; 28 | 29 | @media ${devices.mobile} { 30 | display: none; 31 | } 32 | `; 33 | 34 | export { SiteLogo }; 35 | -------------------------------------------------------------------------------- /frontend/src/components/siteLogo/index.ts: -------------------------------------------------------------------------------- 1 | export * from './SiteLogo'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/spinner/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled, { keyframes } from 'styled-components'; 3 | 4 | const Spinner = () => { 5 | 6 | return ( 7 | 8 | 9 | 10 | ); 11 | }; 12 | 13 | const SpinnerWrapper = styled.div` 14 | height: 100%; 15 | display: flex; 16 | justify-content: center; 17 | align-items: center; 18 | `; 19 | 20 | const rotate = keyframes` 21 | 0% { 22 | transform: rotate(0deg); 23 | } 24 | 100% { 25 | transform: rotate(360deg); 26 | } 27 | `; 28 | 29 | const LoadingCircle = styled.div` 30 | width: 5%; 31 | aspect-ratio: 1 / 1; 32 | border: 0.5rem dotted #bbb; 33 | border-top: 0.5rem dotted #fff; 34 | border-radius: 50px; 35 | animation: ${rotate} 1.2s cubic-bezier(0.01, 1.05, 1,-0.05) infinite; 36 | `; 37 | 38 | export { Spinner }; 39 | -------------------------------------------------------------------------------- /frontend/src/components/spinner/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Spinner'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/themeSwitcher/ThemeSwitcher.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | interface ThemeSwitcherProps { 5 | themeMode: string, 6 | toggleTheme: () => void, 7 | } 8 | 9 | interface ThemeSwitchButtonProps { 10 | themeMode: string; 11 | } 12 | 13 | const ThemeSwitcher = ({ themeMode, toggleTheme}: ThemeSwitcherProps) => { 14 | return ( 15 | 16 | {themeMode === 'light' ? '☀️' : '🌙'} 17 | 18 | ); 19 | }; 20 | 21 | const ThemeSwitchButton = styled('button')` 22 | position: fixed; 23 | bottom: 3%; 24 | right: 3%; 25 | padding: 0.5rem; 26 | font-size: 2rem; 27 | line-height: 2rem; 28 | outline: none; 29 | border: 1px solid; 30 | border-radius: 50%; 31 | transition: 0.2s all ease-in; 32 | z-index: 2000; 33 | border-color: ${({ theme }) => theme.border}; 34 | background-color: ${({ theme }) => theme.background}; 35 | 36 | :hover { 37 | background-color: ${({ theme }) => theme.reverseBackground}; 38 | } 39 | `; 40 | 41 | export { ThemeSwitcher }; -------------------------------------------------------------------------------- /frontend/src/components/themeSwitcher/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ThemeSwitcher'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/toastMsg/ToastMsg.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled, { useTheme, keyframes } from 'styled-components'; 3 | import { useRecoilValue } from 'recoil'; 4 | import { toastMsgState } from '../../atoms/toastMsgAtom'; 5 | import { ReactComponent as CheckIcon } from '../../assets/check.svg'; 6 | import { ReactComponent as ExclamationIcon } from '../../assets/exclamation.svg'; 7 | import { devices } from '../../constants/breakpoints'; 8 | 9 | const ToastMsg = () => { 10 | const theme = useTheme(); 11 | const {type, msg, key} = useRecoilValue(toastMsgState); 12 | 13 | const ICON_SIZE = 24; 14 | const TOAST_COLOR = type === 'INFO' 15 | ? theme.primary 16 | : theme.caution; 17 | 18 | if (type !== 'INIT') { 19 | return ( 20 | 21 | {type === 'INFO' 22 | ? 26 | : 30 | } 31 | {msg} 32 | 33 | ); 34 | } else { 35 | return null; 36 | } 37 | }; 38 | 39 | const toggleVisibility = keyframes` 40 | 0% { visibility: visible; } 41 | 10% { transform: translate(-50%, 6rem); } 42 | 90% { transform: translate(-50%, 6rem); } 43 | 99% { transform: translate(-50%, -4.5rem); } 44 | 100% { visibility: hidden; } 45 | `; 46 | 47 | const ToastMsgWrapper = styled.div` 48 | position: fixed; 49 | top: -4.5rem; 50 | left: 50%; 51 | display: flex; 52 | align-items: center; 53 | min-width: 20rem; 54 | margin: auto; 55 | padding: 1.25rem; 56 | border-radius: 10px; 57 | z-index: 3000; 58 | visibility: hidden; 59 | transform: translate(-50%); 60 | border: 2px solid ${(props) => props.color}; 61 | background-color: ${({ theme }) => theme.background}; 62 | animation: ${toggleVisibility} 3s 1; 63 | 64 | @media ${devices.mobile} { 65 | min-width: 15rem; 66 | } 67 | `; 68 | 69 | const ToastText = styled.p` 70 | font-weight: 500; 71 | font-size: 1.5rem; 72 | text-align: center; 73 | word-break: break-all; 74 | margin: auto; 75 | color: ${(props) => props.color}; 76 | text-shadow: ${({ theme }) => `0px 4px 4px ${theme.defaultShadow}`};; 77 | 78 | @media ${devices.mobile} { 79 | font-weight: 400; 80 | font-size: 1rem; 81 | } 82 | `; 83 | 84 | export { ToastMsg }; 85 | -------------------------------------------------------------------------------- /frontend/src/components/toastMsg/ToastPortal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { ToastMsg } from './ToastMsg'; 4 | 5 | const ToastPortal = () => { 6 | const toastRoot = document.getElementById('toast-root'); 7 | return ReactDOM.createPortal(, toastRoot as HTMLElement ); 8 | }; 9 | 10 | export default ToastPortal; 11 | -------------------------------------------------------------------------------- /frontend/src/components/toastMsg/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ToastMsg'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/userProfile/UserProfile.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { useQueryClient } from 'react-query'; 4 | import { devices } from '../../constants/breakpoints'; 5 | 6 | interface cachedProfile { 7 | name: string, 8 | profileURL: string, 9 | } 10 | 11 | const UserProfile = () => { 12 | const queryClient = useQueryClient(); 13 | const profile: cachedProfile | undefined = queryClient.getQueryData(['userProfile']); 14 | 15 | return ( 16 | 17 | 18 | {profile?.name} 19 | 20 | 23 | 24 | ); 25 | }; 26 | 27 | const ProfileWrapper = styled.div` 28 | display: flex; 29 | align-items: center; 30 | `; 31 | 32 | const ProfileImg = styled.img` 33 | width: 2rem; 34 | height: 2rem; 35 | border-radius: 50px; 36 | `; 37 | 38 | const UserName = styled.span` 39 | font-size: 1rem; 40 | font-weight: 300; 41 | padding: 0.5rem; 42 | color: ${({ theme }) => theme.text}; 43 | text-shadow: ${({ theme }) => `0px 4px 4px ${theme.defaultShadow}`};; 44 | 45 | @media ${devices.mobile} { 46 | display: none; 47 | } 48 | `; 49 | 50 | export { UserProfile }; 51 | -------------------------------------------------------------------------------- /frontend/src/components/userProfile/index.ts: -------------------------------------------------------------------------------- 1 | export * from './UserProfile'; -------------------------------------------------------------------------------- /frontend/src/constants/breakpoints.ts: -------------------------------------------------------------------------------- 1 | const breakpoints = { 2 | mobile: '390px', 3 | }; 4 | 5 | const devices = { 6 | mobile: `(max-width: ${breakpoints.mobile})`, 7 | }; 8 | 9 | export { devices }; 10 | -------------------------------------------------------------------------------- /frontend/src/constants/modalContent.ts: -------------------------------------------------------------------------------- 1 | export interface QUESTION_MAP { 2 | title: string; 3 | description: string; 4 | } 5 | 6 | export interface TYPE_QUESTION_MAP { 7 | [key: string] : QUESTION_MAP; 8 | } 9 | 10 | const MODAL_CONTENT: TYPE_QUESTION_MAP = { 11 | 'DELETE': { 12 | title: '정말로 삭제하시겠습니까?', 13 | description: '한 번 삭제한 문서는 복원할 수 없습니다. 진행하시겠습니까?' 14 | }, 15 | 'BOOKMARK': { 16 | title: '북마크에 등록하시겠습니까?', 17 | description: '등록한 문서는 북마크 페이지에서 확인할 수 있습니다.' 18 | }, 19 | 'UNBOOKMARK': { 20 | title: '북마크를 해제하시겠습니까?', 21 | description: '북마크를 해제해도 다시 북마크할 수 있습니다.' 22 | } 23 | }; 24 | 25 | export default MODAL_CONTENT; 26 | -------------------------------------------------------------------------------- /frontend/src/constants/styled.ts: -------------------------------------------------------------------------------- 1 | export const COLOR_BACKGROUND = '#FEFEFF'; 2 | 3 | export const COLOR_ACTIVE = '#3A7DFF'; 4 | 5 | export const COLOR_BLACK = '#222222'; 6 | 7 | export const COLOR_BORDER = '#BBBBBB'; 8 | 9 | export const COLOR_WHITE = '#FFFFFF'; 10 | 11 | export const COLOR_UNSELECTED = '#A5A5A5'; 12 | 13 | export const COLOR_CAUTION = '#FF5757'; 14 | 15 | export const FILTER_TO_ACTIVE = 16 | 'invert(46%) sepia(76%) saturate(4291%) hue-rotate(209deg) brightness(105%) contrast(101%)'; 17 | 18 | export const FILTER_TO_CAUTION = 19 | 'invert(30%) sepia(81%) saturate(1539%) hue-rotate(337deg) brightness(105%) contrast(90%);'; 20 | 21 | export const FILTER_TO_BLACK = 22 | 'invert(9%) sepia(16%) saturate(11%) hue-rotate(330deg) brightness(100%) contrast(91%)'; 23 | -------------------------------------------------------------------------------- /frontend/src/core/crdt-linear-ll/char.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from 'uuid'; 2 | 3 | class Char { 4 | id: string; 5 | 6 | leftId: string; 7 | 8 | rightId: string; 9 | 10 | siteId: string; 11 | 12 | value: string; 13 | 14 | tombstone: boolean; 15 | 16 | constructor(leftId: string, rightId: string, siteId: string, value: string, id: string = uuidv4()) { 17 | this.id = id; 18 | this.leftId = leftId; 19 | this.rightId = rightId; 20 | this.siteId = siteId; 21 | this.value = value; 22 | this.tombstone = false; 23 | } 24 | } 25 | 26 | export default Char; -------------------------------------------------------------------------------- /frontend/src/core/crdt-linear/char.ts: -------------------------------------------------------------------------------- 1 | export default class Char { 2 | index: CRDTIndex; 3 | 4 | siteId: string; 5 | 6 | value: string; 7 | 8 | constructor(index: CRDTIndex, siteId: string, value: string) { 9 | this.index = index; 10 | this.siteId = siteId; 11 | this.value = value; 12 | } 13 | } -------------------------------------------------------------------------------- /frontend/src/core/cursor/cursor.ts: -------------------------------------------------------------------------------- 1 | import CodeMirror from 'codemirror'; 2 | 3 | class Cursor { 4 | marker?: CodeMirror.TextMarker; 5 | 6 | color: string; 7 | 8 | name: string; 9 | 10 | height: number; 11 | 12 | constructor(color: string, name: string) { 13 | this.color = color; 14 | this.name = name; 15 | this.height = 0; 16 | } 17 | 18 | updateCursor(editor: CodeMirror.Editor, cursorPosition: CodeMirror.Position) { 19 | this.removeCursor(); 20 | const cursorCoords = editor.cursorCoords(cursorPosition); 21 | const cursorHolder = document.createElement('span'); 22 | this.height = cursorCoords.bottom - cursorCoords.top; 23 | 24 | cursorHolder.classList.add('remote-cursor'); 25 | cursorHolder.style.borderLeftColor = this.color; 26 | cursorHolder.style.height = `${this.height}px`; 27 | 28 | this.showCursorName(cursorHolder); 29 | 30 | this.marker = editor.setBookmark(cursorPosition, { 31 | widget: cursorHolder, 32 | insertLeft: true 33 | }); 34 | } 35 | 36 | private showCursorName(cursorHolder: HTMLSpanElement) { 37 | const nameHolder = document.createElement('div'); 38 | nameHolder.classList.add('remote-cursor-name'); 39 | 40 | cursorHolder.addEventListener('mouseenter', () => { 41 | nameHolder.innerHTML = this.name; 42 | nameHolder.style.top = `-${this.height}px`; 43 | nameHolder.style.backgroundColor = this.color; 44 | nameHolder.style.color = this.getContrastColor(this.color); 45 | 46 | nameHolder.classList.remove('hide'); 47 | cursorHolder.appendChild(nameHolder); 48 | }); 49 | 50 | cursorHolder.addEventListener('mouseleave', () => { 51 | nameHolder.classList.add('hide'); 52 | 53 | nameHolder.parentNode?.removeChild(nameHolder); 54 | }); 55 | } 56 | 57 | private getContrastColor(color: string) { 58 | const hex = color.replace('#', ''); 59 | const c_r = parseInt(hex.substring(0, 0 + 2), 16); 60 | const c_g = parseInt(hex.substring(2, 2 + 2), 16); 61 | const c_b = parseInt(hex.substring(4, 4 + 2), 16); 62 | const brightness = (c_r * 299 + c_g * 587 + c_b * 114) / 1000; 63 | return brightness > 155 ? '#222222' : '#FFFFFF'; 64 | } 65 | 66 | removeCursor() { 67 | if (this.marker) { 68 | this.marker.clear(); 69 | this.marker = undefined; 70 | } 71 | } 72 | } 73 | 74 | export { Cursor }; 75 | -------------------------------------------------------------------------------- /frontend/src/core/editorWithCRDT/editorWithCRDT.ts: -------------------------------------------------------------------------------- 1 | import CodeMirror from 'codemirror'; 2 | 3 | const addChangeOnEditor = (editor: CodeMirror.Editor, from: CodeMirror.Position, to: CodeMirror.Position, text: string) => { 4 | const adjust = editor 5 | .getDoc() 6 | .listSelections() 7 | .findIndex(({ anchor, head }) => { 8 | return CodeMirror.cmpPos(anchor, head) == 0 && CodeMirror.cmpPos(anchor, from) == 0; 9 | }); 10 | editor.operation(() => { 11 | editor.getDoc().replaceRange(text, from, to, 'remote'); 12 | if (adjust > -1) { 13 | const range = editor.getDoc().listSelections()[adjust]; 14 | if ( 15 | range && 16 | CodeMirror.cmpPos(range.head, CodeMirror.changeEnd({ from, to, text: [text] })) == 0 17 | ) { 18 | const ranges: CodeMirror.Range[] = editor.getDoc().listSelections().slice(); 19 | ranges[adjust] = { 20 | ...ranges[adjust], 21 | anchor: from, 22 | head: to 23 | }; 24 | editor.getDoc().setSelections(ranges); 25 | } 26 | } 27 | }); 28 | }; 29 | 30 | const remoteInsertOnEditor = (insertedIndex: number, insertedChars: string, editor: CodeMirror.Editor) => { 31 | const position = editor.getDoc().posFromIndex(insertedIndex); 32 | addChangeOnEditor(editor, position, position, insertedChars); 33 | }; 34 | 35 | const remoteDeleteOnEditor = (deleteStartIndex: number, deleteEndIndex: number, editor: CodeMirror.Editor) => { 36 | const positionFrom = editor.getDoc().posFromIndex(deleteStartIndex); 37 | const positionTo = editor.getDoc().posFromIndex(deleteEndIndex); 38 | addChangeOnEditor(editor, positionFrom, positionTo, ''); 39 | }; 40 | 41 | const remoteReplaceOnEditor = (replacedIndex: number, replacedChar: string, editor: CodeMirror.Editor) => { 42 | const positionFrom = editor.getDoc().posFromIndex(replacedIndex); 43 | const positionTo = editor.getDoc().posFromIndex(replacedIndex + 1); 44 | addChangeOnEditor(editor, positionFrom, positionTo, replacedChar); 45 | }; 46 | 47 | export {remoteInsertOnEditor, remoteDeleteOnEditor, remoteReplaceOnEditor}; 48 | -------------------------------------------------------------------------------- /frontend/src/core/sockets/sockets.ts: -------------------------------------------------------------------------------- 1 | import { io } from 'socket.io-client'; 2 | const { REACT_APP_SOCKET_URL } = process.env; 3 | 4 | const socket = io(String(REACT_APP_SOCKET_URL), { 5 | path: '/socket/socket', 6 | }); 7 | 8 | export default socket; 9 | -------------------------------------------------------------------------------- /frontend/src/hooks/useDarkMode.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | const useDarkMode = () => { 4 | const [themeMode, setTheme] = useState('light'); 5 | 6 | const saveThemeMode = (themeMode: string) => { 7 | window.localStorage.setItem('themeMode', themeMode); 8 | setTheme(themeMode); 9 | }; 10 | 11 | const toggleTheme = () => { 12 | if (themeMode === 'light') { 13 | saveThemeMode('dark'); 14 | } else { 15 | saveThemeMode('light'); 16 | } 17 | }; 18 | 19 | useEffect(() => { 20 | const isUserDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; 21 | if (isUserDarkMode) { 22 | setTheme('dark'); 23 | } else { 24 | const localTheme = window.localStorage.getItem('themeMode'); 25 | localTheme ? setTheme(localTheme) : setTheme('light'); 26 | } 27 | }, []); 28 | 29 | return {themeMode, toggleTheme}; 30 | }; 31 | 32 | export default useDarkMode; -------------------------------------------------------------------------------- /frontend/src/hooks/useDebounce.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | const useDebounce = () => { 4 | const [debounceTimer, setDebounceTimer] = useState(); 5 | 6 | useEffect(() => { 7 | return () => { 8 | clearTimeout(debounceTimer); 9 | }; 10 | }, [debounceTimer]); 11 | 12 | return [setDebounceTimer]; 13 | }; 14 | 15 | export default useDebounce; 16 | -------------------------------------------------------------------------------- /frontend/src/hooks/useDocumentTitle.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import socket from '../core/sockets/sockets'; 3 | import useToast from './useToast'; 4 | 5 | const { REACT_APP_API_URL } = process.env; 6 | 7 | const useDocumentTitle = (initialTitle: string) => { 8 | const [documentTitle, setDocumentTitle] = useState(initialTitle); 9 | const { alertToast } = useToast(); 10 | 11 | useEffect(() => { 12 | socket.connect(); 13 | socket.on('new-title', setDocumentTitle); 14 | return () => { 15 | socket.removeAllListeners(); 16 | }; 17 | }, [documentTitle]); 18 | 19 | const fetchNewTitle = async (document_id: string) => { 20 | try { 21 | await fetch(`${REACT_APP_API_URL}/document/${document_id}/save-title`, { 22 | method: 'POST', 23 | headers: { 24 | 'Content-Type': 'application/json' 25 | }, 26 | credentials: 'include', 27 | body: JSON.stringify({ title: documentTitle }) 28 | }); 29 | } catch (err) { 30 | alertToast('WARNING', '제목 저장에 실패했어요. 🥲 다시 시도해주세요.'); 31 | } 32 | }; 33 | 34 | const updateDocumentTitle = (document_id: string) => { 35 | fetchNewTitle(document_id); 36 | socket.emit('update-title', documentTitle); 37 | }; 38 | 39 | return { 40 | documentTitle, 41 | setDocumentTitle, 42 | updateDocumentTitle, 43 | }; 44 | }; 45 | 46 | export default useDocumentTitle; 47 | -------------------------------------------------------------------------------- /frontend/src/hooks/useModal.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | interface ModalDataState { 4 | modalType: string | null, 5 | actionHandler: WrappedHandler | null, 6 | } 7 | 8 | const useModal = () => { 9 | const [onModal, setOnModal] = useState(false); 10 | const [modalData, setModalData] = useState({modalType: null, actionHandler: null}); 11 | 12 | const toggleModal = () => { 13 | setOnModal(!onModal); 14 | }; 15 | 16 | const setupModalData = (modalType: string, actionHandler: WrappedHandler) => { 17 | setModalData({ 18 | modalType, 19 | actionHandler, 20 | }); 21 | }; 22 | 23 | return {onModal, toggleModal, modalData, setupModalData}; 24 | }; 25 | 26 | export default useModal; 27 | -------------------------------------------------------------------------------- /frontend/src/hooks/usePageName.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { useLocation } from 'react-router-dom'; 3 | 4 | interface pageNames { 5 | [key: string]: string; 6 | } 7 | 8 | const usePageName = () => { 9 | const [pageName, setPageName] = useState(''); 10 | const location = useLocation(); 11 | 12 | const pageNamesByPath: pageNames = { 13 | main: '최근 문서 목록', 14 | private: '내 문서함', 15 | shared: '공유 문서함', 16 | bookmark: '북마크', 17 | trash: '휴지통' 18 | }; 19 | 20 | useEffect(() => { 21 | const [lastPath] = location.pathname.split('/').slice(-1); 22 | setPageName(pageNamesByPath[lastPath]); 23 | }); 24 | 25 | return { pageName }; 26 | }; 27 | 28 | export default usePageName; 29 | -------------------------------------------------------------------------------- /frontend/src/hooks/useSortOption.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | interface OptionMap { 4 | [key: string] : string 5 | } 6 | 7 | const useSortOption = (initialOption: string) => { 8 | const [option, setOption] = useState(initialOption); 9 | const optionList: OptionMap = { 10 | 'lastVisited': '최근 방문순', 11 | 'title': '제목순', 12 | 'createdAt': '생성일순' 13 | }; 14 | 15 | return { option, optionList, setOption }; 16 | }; 17 | 18 | export default useSortOption; 19 | -------------------------------------------------------------------------------- /frontend/src/hooks/useToast.ts: -------------------------------------------------------------------------------- 1 | import { useSetRecoilState } from 'recoil'; 2 | import { toastMsgState } from '../atoms/toastMsgAtom'; 3 | 4 | const useToast = () => { 5 | const setToastMsg = useSetRecoilState(toastMsgState); 6 | 7 | const alertToast = (type: string, msg: string) => { 8 | setToastMsg({ 9 | type, 10 | msg, 11 | key: +new Date(), 12 | }); 13 | }; 14 | 15 | return { 16 | alertToast 17 | }; 18 | }; 19 | 20 | export default useToast; 21 | -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | .remote-cursor { 2 | position: absolute; 3 | border-left-style: solid; 4 | border-left-width: 2px; 5 | padding: 0; 6 | } 7 | 8 | .remote-cursor-name { 9 | white-space: nowrap; 10 | position: absolute; 11 | z-index: 1000; 12 | left: -2px; 13 | padding: 1px 4px; 14 | border-radius: 2px 2px 2px 0; 15 | } 16 | 17 | .hide { 18 | display: none; 19 | } 20 | 21 | .editor-preview-side { 22 | padding: 0 !important; 23 | max-height: calc(100vh - 140px) !important; 24 | } 25 | 26 | .editor-preview-active { 27 | display: none; 28 | } 29 | 30 | .editor-toolbar { 31 | z-index: 0; 32 | } 33 | 34 | .fullscreen { 35 | z-index: 500 !important; 36 | } 37 | 38 | .CodeMirror { 39 | padding: 0 !important; 40 | background-color: transparent !important; 41 | } 42 | 43 | .CodeMirror-scroll { 44 | padding: 0 !important; 45 | max-height: calc(100vh - 140px) !important; 46 | } 47 | -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import App from './App'; 4 | import './index.css'; 5 | const { REACT_APP_NODE_ENV } = process.env; 6 | 7 | if (REACT_APP_NODE_ENV === 'mock') { 8 | import('./mocks/worker').then(({ worker }) => 9 | worker.start({ 10 | onUnhandledRequest: 'bypass' 11 | }) 12 | ); 13 | } 14 | 15 | const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); 16 | root.render(); 17 | -------------------------------------------------------------------------------- /frontend/src/mocks/dummy/docList.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from 'uuid'; 2 | 3 | interface roleEnumObj { 4 | [key: number]: string; 5 | } 6 | 7 | const roleEnumMock: roleEnumObj = { 8 | 1: 'view', 9 | 2: 'edit', 10 | 3: 'owner' 11 | }; 12 | 13 | const getRandomDate = () => { 14 | const date = new Date().getTime(); 15 | return new Date(date - Math.floor(Math.random() * 10000000000)); 16 | }; 17 | 18 | const createDocumentList = (counts: number): DocListItem[] => { 19 | return Array(counts) 20 | .fill(null) 21 | .map((_) => { 22 | return { 23 | id: uuidv4(), 24 | title: Math.random().toFixed(4).slice(2).toString(), 25 | lastVisited: new Date(getRandomDate()).getTime().toString(), 26 | role: roleEnumMock[Math.floor(Math.random() * 3 + 1)], 27 | createdAt: new Date(getRandomDate()).getTime().toString(), 28 | isBookmarked: false 29 | }; 30 | }); 31 | }; 32 | 33 | export default createDocumentList; 34 | -------------------------------------------------------------------------------- /frontend/src/mocks/dummy/document.ts: -------------------------------------------------------------------------------- 1 | import { v1 as uuidv1 } from 'uuid'; 2 | 3 | const createDocumentId = () => { 4 | return { 5 | id: uuidv1(), 6 | title: Math.random().toFixed(4).slice(2).toString(), 7 | lastVisited: '2022-12-01', 8 | role: 'owner', 9 | createdAt: '2022-11-11', 10 | isBookmarked: false 11 | }; 12 | }; 13 | 14 | export default createDocumentId; 15 | -------------------------------------------------------------------------------- /frontend/src/mocks/dummy/userProfile.ts: -------------------------------------------------------------------------------- 1 | import { v1 as uuidv1 } from 'uuid'; 2 | 3 | const createUserProfile = (counts: number): UserProfile[] => { 4 | return Array(counts) 5 | .fill(null) 6 | .map((_) => { 7 | return { 8 | profileURL: 'https://picsum.photos/200', 9 | name: uuidv1().slice(0, 8) 10 | }; 11 | }); 12 | }; 13 | 14 | export default createUserProfile; 15 | -------------------------------------------------------------------------------- /frontend/src/mocks/handler.ts: -------------------------------------------------------------------------------- 1 | import { rest } from 'msw'; 2 | import createDocumentList from './dummy/docList'; 3 | import createDocumentId from './dummy/document'; 4 | import createUserProfile from './dummy/userProfile'; 5 | import charMap from './dummy/charMap'; 6 | 7 | interface generateEnumObj { 8 | [key: string]: (counts: number) => DocListItem[] | UserProfile[] | DocListItem; 9 | } 10 | 11 | const generateFunc: generateEnumObj = { 12 | docList: createDocumentList, 13 | newDocument: createDocumentId, 14 | userProfile: createUserProfile 15 | }; 16 | 17 | const generateDummy = (target: string, counts = 1) => { 18 | return generateFunc[target](counts); 19 | }; 20 | 21 | const dummyDocList = generateDummy('docList', 30) as DocListItem[]; 22 | 23 | export const handler = [ 24 | rest.get('/user-document/recent', (req, res, ctx) => { 25 | return res(ctx.status(200), ctx.json(dummyDocList)); 26 | }), 27 | // rest.get('/user-document/shared', (req, res, ctx) => { 28 | // // TODO: 공유된 문서만 반환하도록 수정 29 | // const sharedDocList = dummyDocList.filter(doc => doc.shared === true && doc.isDeleted === false); 30 | // return res(ctx.status(200), ctx.json(sharedDocList)); 31 | // }), 32 | // rest.get('/bookmark', (req, res, ctx) => { 33 | // // TODO: 북마크된 문서만 반환하도록 수정 34 | // const bookmarkDocList = dummyDocList.filter(doc => doc.bookmark === true && doc.isDeleted === false); 35 | // return res(ctx.status(200), ctx.json(bookmarkDocList)); 36 | // }), 37 | // rest.post('/bookmark/:id', (req, res, ctx) => { 38 | // dummyDocList.some(doc => { 39 | // if (doc.id === req.params.id) { 40 | // doc.bookmark = true; 41 | // return true; 42 | // } 43 | // return false; 44 | // }); 45 | // return res(ctx.status(200)); 46 | // }), 47 | rest.get('/document/:id', (req, res, ctx) => { 48 | const [targetDocument] = dummyDocList.filter((doc) => doc.id === req.params.id); 49 | return res(ctx.status(200), ctx.json({ ...targetDocument, content: charMap })); 50 | }), 51 | // rest.delete('/document/:id', (req, res, ctx) => { 52 | // dummyDocList.some(doc => { 53 | // if (doc.id === req.params.id) { 54 | // doc.isDeleted = true; 55 | // return true; 56 | // } 57 | // return false; 58 | // }); 59 | // return res(ctx.status(200)); 60 | // }), 61 | rest.post('/document', (req, res, ctx) => { 62 | const newDocument = generateDummy('newDocument') as DocListItem; 63 | dummyDocList.push(newDocument); 64 | return res(ctx.status(200), ctx.json(newDocument)); 65 | }), 66 | rest.get('/user/profile', (req, res, ctx) => { 67 | const [useProfile] = generateDummy('userProfile') as UserProfile[]; 68 | return res(ctx.status(200), ctx.json(useProfile)); 69 | }) 70 | ]; 71 | -------------------------------------------------------------------------------- /frontend/src/mocks/server.ts: -------------------------------------------------------------------------------- 1 | import { setupServer } from 'msw/node'; 2 | import { handler } from './handler'; 3 | 4 | export const server = setupServer(...handler); 5 | -------------------------------------------------------------------------------- /frontend/src/mocks/worker.ts: -------------------------------------------------------------------------------- 1 | import { setupWorker } from 'msw'; 2 | import { handler } from './handler'; 3 | 4 | export const worker = setupWorker(...handler); 5 | -------------------------------------------------------------------------------- /frontend/src/pages/BookmarkPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense } from 'react'; 2 | import styled from 'styled-components'; 3 | import usePageName from '../hooks/usePageName'; 4 | import useSortOption from '../hooks/useSortOption'; 5 | import { DocList } from '../components/docList'; 6 | import { Spinner } from '../components/spinner'; 7 | import { Dropdown } from '../components/dropdown'; 8 | import { devices } from '../constants/breakpoints'; 9 | 10 | const BookmarkPage = () => { 11 | const { pageName } = usePageName(); 12 | const {option, optionList, setOption} = useSortOption('lastVisited'); 13 | 14 | return ( 15 | 16 | 17 | {pageName} 18 | 23 | 24 | }> 25 | 26 | 27 | 28 | ); 29 | }; 30 | 31 | const ContentWrapper = styled.section` 32 | flex: 1; 33 | margin: 3rem 3.5rem; 34 | overflow-y: scroll; 35 | -ms-overflow-style: none; 36 | scrollbar-width: none; 37 | &::-webkit-scrollbar { 38 | display: none; 39 | } 40 | 41 | @media ${devices.mobile} { 42 | margin: auto; 43 | display: flex; 44 | flex-direction: column; 45 | align-items: flex-end; 46 | } 47 | `; 48 | 49 | const ContentHeaderGroup = styled.div` 50 | display: flex; 51 | justify-content: space-between; 52 | align-items: center; 53 | margin-bottom: 2rem; 54 | 55 | @media ${devices.mobile} { 56 | margin-top: 2rem; 57 | } 58 | `; 59 | 60 | const PageName = styled.h1` 61 | font-weight: 800; 62 | font-size: 2rem; 63 | color: ${({ theme }) => theme.text}; 64 | 65 | @media ${devices.mobile} { 66 | display: none; 67 | } 68 | `; 69 | 70 | export default BookmarkPage; 71 | -------------------------------------------------------------------------------- /frontend/src/pages/DocumentPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense } from 'react'; 2 | import { useParams } from 'react-router-dom'; 3 | import useDocumentDataQuery from '../query/document/useDocumentDataQuery'; 4 | import { EditorHeader } from '../components/editorHeader'; 5 | import { Editor } from '../components/editor/Editor'; 6 | import { Spinner } from '../components/spinner'; 7 | 8 | const DocumentPage = () => { 9 | const { document_id } = useParams(); 10 | const { data: documentData } = useDocumentDataQuery(document_id as string); 11 | 12 | return ( 13 | }> 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | export default DocumentPage; 21 | -------------------------------------------------------------------------------- /frontend/src/pages/LandingPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import debugImage from '../assets/background-1.svg'; 4 | import talkingImage from '../assets/background-2.svg'; 5 | import { LoginButton } from '../components/loginButton'; 6 | import { SiteLogo } from '../components/siteLogo'; 7 | import { devices } from '../constants/breakpoints'; 8 | 9 | const LandingPage = () => { 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | 귀찮은 공동 문서 작업, Codocs에서 한번에. 17 | 18 | 19 | 20 | ); 21 | }; 22 | 23 | const PageWrapper = styled.div` 24 | width: 100vw; 25 | height: 100vh; 26 | display: flex; 27 | flex-direction: column; 28 | justify-content: space-evenly; 29 | align-items: center; 30 | padding: 2rem; 31 | 32 | @media ${devices.mobile} { 33 | justify-content: space-between; 34 | padding: 1rem; 35 | } 36 | `; 37 | 38 | const LogoWrapper = styled.div` 39 | align-self: flex-start; 40 | pointer-events: none; 41 | `; 42 | 43 | const Title = styled.span` 44 | font-weight: 700; 45 | font-size: 4rem; 46 | align-self: center; 47 | text-align: center; 48 | padding: 2rem; 49 | color: ${({ theme }) => theme.text}; 50 | 51 | @media ${devices.mobile} { 52 | font-size: 2.25rem; 53 | text-align: center; 54 | padding: 0; 55 | } 56 | `; 57 | 58 | const DebugImage = styled.img` 59 | display: block; 60 | width: 30vw; 61 | height: 30vh; 62 | align-self: flex-end; 63 | 64 | @media ${devices.mobile} { 65 | width: auto; 66 | height: 25vh; 67 | } 68 | `; 69 | 70 | const TalkingImage = styled.img` 71 | display: block; 72 | width: 30vw; 73 | height: 30vh; 74 | align-self: flex-start; 75 | 76 | @media ${devices.mobile} { 77 | width: auto; 78 | height: 25vh; 79 | } 80 | `; 81 | 82 | export default LandingPage; 83 | -------------------------------------------------------------------------------- /frontend/src/pages/MainLayout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import useGetProfileQuery from '../query/profile/useGetProfileQuery'; 4 | import { Header } from '../components/header'; 5 | import { SideBar } from '../components/sideBar'; 6 | import { Outlet } from 'react-router-dom'; 7 | import { devices } from '../constants/breakpoints'; 8 | 9 | const MainLayout = () => { 10 | const { data: profile } = useGetProfileQuery(); 11 | if (profile.name === undefined) { 12 | window.location.href = '/'; 13 | } 14 | 15 | return ( 16 | <> 17 |
18 | 19 | 20 | 21 | 22 | 23 | ); 24 | }; 25 | 26 | const Container = styled.main` 27 | display: flex; 28 | 29 | @media ${devices.mobile} { 30 | flex-direction: column; 31 | } 32 | `; 33 | 34 | export default MainLayout; 35 | -------------------------------------------------------------------------------- /frontend/src/pages/NotFoundPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import ErrorIcon from '../assets/404.svg'; 4 | import { useNavigate } from 'react-router-dom'; 5 | import { devices } from '../constants/breakpoints'; 6 | 7 | const NotFoundPage = () => { 8 | const navigate = useNavigate(); 9 | 10 | const handleGoBack = () => { 11 | navigate('/', { replace: true }); 12 | }; 13 | 14 | return ( 15 | 16 | 17 | 404 18 | Page Not Found 19 | 페이지를 찾을 수 없습니다. 20 | 21 | 돌아가기 22 | 23 | 24 | 25 | 26 | ); 27 | }; 28 | 29 | const PageWrapper = styled.div` 30 | width: 100vw; 31 | height: 100vh; 32 | display: flex; 33 | flex-direction: column; 34 | justify-content: space-evenly; 35 | align-items: center; 36 | padding: 2rem; 37 | `; 38 | 39 | const ErrorGroup = styled.div` 40 | display: flex; 41 | flex-direction: column; 42 | justify-content: space-between; 43 | align-items: center; 44 | `; 45 | 46 | const ErrorCode = styled.strong` 47 | font-size: 4rem; 48 | line-height: 5rem; 49 | color: ${({ theme }) => theme.primary}; 50 | `; 51 | 52 | const ErrorMessage = styled.strong` 53 | font-size: 3rem; 54 | line-height: 4rem; 55 | color: ${({ theme }) => theme.text}; 56 | 57 | @media ${devices.mobile} { 58 | font-size: 2rem; 59 | } 60 | `; 61 | 62 | const PageDescription = styled.span` 63 | font-size: 2rem; 64 | line-height: 6rem; 65 | color: ${({ theme }) => theme.text}; 66 | 67 | @media ${devices.mobile} { 68 | font-size: 1.5rem; 69 | } 70 | `; 71 | 72 | const ErrorImage = styled.img` 73 | width: 30vw; 74 | 75 | @media ${devices.mobile} { 76 | width: auto; 77 | height: 30vh; 78 | } 79 | `; 80 | 81 | const ReplaceButton = styled.button` 82 | font-weight: 700; 83 | font-size: 1.5rem; 84 | border-radius: 20px; 85 | padding: 1rem 3rem; 86 | color: ${({ theme }) => theme.white}; 87 | background-color: ${({ theme }) => theme.primary}; 88 | 89 | @media ${devices.mobile} { 90 | font-weight: 500; 91 | font-size: 1rem; 92 | padding: 1rem 1.5rem; 93 | } 94 | `; 95 | 96 | export default NotFoundPage; 97 | -------------------------------------------------------------------------------- /frontend/src/pages/PrivatePage.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense } from 'react'; 2 | import styled from 'styled-components'; 3 | import usePageName from '../hooks/usePageName'; 4 | import useSortOption from '../hooks/useSortOption'; 5 | import { DocList } from '../components/docList'; 6 | import { Spinner } from '../components/spinner'; 7 | import { Dropdown } from '../components/dropdown'; 8 | import { devices } from '../constants/breakpoints'; 9 | 10 | const PrivatePage = () => { 11 | const { pageName } = usePageName(); 12 | const {option, optionList, setOption} = useSortOption('lastVisited'); 13 | 14 | return ( 15 | 16 | 17 | {pageName} 18 | 23 | 24 | }> 25 | 26 | 27 | 28 | ); 29 | }; 30 | 31 | const ContentWrapper = styled.section` 32 | flex: 1; 33 | margin: 3rem 3.5rem; 34 | overflow-y: scroll; 35 | -ms-overflow-style: none; 36 | scrollbar-width: none; 37 | &::-webkit-scrollbar { 38 | display: none; 39 | } 40 | 41 | @media ${devices.mobile} { 42 | margin: auto; 43 | display: flex; 44 | flex-direction: column; 45 | align-items: flex-end; 46 | } 47 | `; 48 | 49 | const ContentHeaderGroup = styled.div` 50 | display: flex; 51 | justify-content: space-between; 52 | align-items: center; 53 | margin-bottom: 2rem; 54 | 55 | @media ${devices.mobile} { 56 | margin-top: 2rem; 57 | } 58 | `; 59 | 60 | const PageName = styled.h1` 61 | font-weight: 800; 62 | font-size: 2rem; 63 | color: ${({ theme }) => theme.text}; 64 | 65 | @media ${devices.mobile} { 66 | display: none; 67 | } 68 | `; 69 | 70 | export default PrivatePage; 71 | -------------------------------------------------------------------------------- /frontend/src/pages/SharedPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense } from 'react'; 2 | import styled from 'styled-components'; 3 | import usePageName from '../hooks/usePageName'; 4 | import useSortOption from '../hooks/useSortOption'; 5 | import { DocList } from '../components/docList'; 6 | import { Spinner } from '../components/spinner'; 7 | import { Dropdown } from '../components/dropdown'; 8 | import { devices } from '../constants/breakpoints'; 9 | 10 | const SharedPage = () => { 11 | const { pageName } = usePageName(); 12 | const {option, optionList, setOption} = useSortOption('lastVisited'); 13 | 14 | return ( 15 | 16 | 17 | {pageName} 18 | 23 | 24 | }> 25 | 26 | 27 | 28 | ); 29 | }; 30 | 31 | const ContentWrapper = styled.section` 32 | flex: 1; 33 | margin: 3rem 3.5rem; 34 | overflow-y: scroll; 35 | -ms-overflow-style: none; 36 | scrollbar-width: none; 37 | &::-webkit-scrollbar { 38 | display: none; 39 | } 40 | 41 | @media ${devices.mobile} { 42 | margin: auto; 43 | display: flex; 44 | flex-direction: column; 45 | align-items: flex-end; 46 | } 47 | `; 48 | 49 | const ContentHeaderGroup = styled.div` 50 | display: flex; 51 | justify-content: space-between; 52 | align-items: center; 53 | margin-bottom: 2rem; 54 | 55 | @media ${devices.mobile} { 56 | margin-top: 2rem; 57 | } 58 | `; 59 | 60 | const PageName = styled.h1` 61 | font-weight: 800; 62 | font-size: 2rem; 63 | color: ${({ theme }) => theme.text}; 64 | 65 | @media ${devices.mobile} { 66 | display: none; 67 | } 68 | `; 69 | 70 | export default SharedPage; 71 | -------------------------------------------------------------------------------- /frontend/src/query/document/useAddDocumentBookmarkMutation.tsx: -------------------------------------------------------------------------------- 1 | import { useQueryClient, useMutation } from 'react-query'; 2 | import useToast from '../../hooks/useToast'; 3 | 4 | const { REACT_APP_API_URL } = process.env; 5 | 6 | const useAddDocumentBookmarkMutation = (documentType: string) => { 7 | const { alertToast } = useToast(); 8 | const queryClient = useQueryClient(); 9 | 10 | return useMutation((id: string) => { 11 | return fetch(`${REACT_APP_API_URL}/user-document/${id}/bookmark`, { 12 | method: 'POST', 13 | headers: { 14 | 'Content-Type': 'application/json' 15 | }, 16 | credentials: 'include' 17 | }); 18 | }, 19 | { 20 | onSuccess: () => { 21 | queryClient.invalidateQueries(documentType); 22 | alertToast('INFO', '북마크에 추가되었습니다.'); 23 | }, 24 | onError: () => { 25 | alertToast('WARNING', '북마크 추가에 실패했습니다. 다시 시도해주세요.'); 26 | } 27 | } 28 | ); 29 | }; 30 | 31 | export default useAddDocumentBookmarkMutation; 32 | -------------------------------------------------------------------------------- /frontend/src/query/document/useDeleteDocumentMutation.tsx: -------------------------------------------------------------------------------- 1 | import { useQueryClient, useMutation } from 'react-query'; 2 | import useToast from '../../hooks/useToast'; 3 | 4 | const { REACT_APP_API_URL } = process.env; 5 | 6 | const useDeleteDocumentMutation = (documentType: string) => { 7 | const { alertToast } = useToast(); 8 | const queryClient = useQueryClient(); 9 | 10 | return useMutation((id: string) => { 11 | return fetch(`${REACT_APP_API_URL}/document/${id}`, { 12 | method: 'DELETE', 13 | headers: { 14 | 'Content-Type': 'application/json' 15 | }, 16 | credentials: 'include' 17 | }); 18 | }, 19 | { 20 | onSuccess: () => { 21 | queryClient.invalidateQueries(documentType); 22 | alertToast('INFO', '성공적으로 삭제했습니다.'); 23 | }, 24 | onError: () => { 25 | alertToast('WARNING', '문서 삭제에 실패했습니다. 다시 시도해주세요.'); 26 | } 27 | } 28 | ); 29 | }; 30 | 31 | export default useDeleteDocumentMutation; 32 | -------------------------------------------------------------------------------- /frontend/src/query/document/useDocumentDataQuery.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from 'react-query'; 2 | import { fetchDataFromPath } from '../../utils/fetchBeforeRender'; 3 | 4 | const { REACT_APP_API_URL } = process.env; 5 | 6 | const useDocumentDataQuery = (documentId: string) => { 7 | return useQuery( 8 | [documentId], 9 | () => fetchDataFromPath(`${REACT_APP_API_URL}/document/${documentId}`), 10 | { 11 | cacheTime: 0, 12 | suspense: true, 13 | refetchOnWindowFocus: false, 14 | } 15 | ); 16 | }; 17 | 18 | export default useDocumentDataQuery; 19 | -------------------------------------------------------------------------------- /frontend/src/query/document/useGetDocumentQuery.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from 'react-query'; 2 | import useToast from '../../hooks/useToast'; 3 | import { fetchDataFromPath } from '../../utils/fetchBeforeRender'; 4 | 5 | const { REACT_APP_API_URL } = process.env; 6 | 7 | const useGetDocumentQuery = (documentType: string) => { 8 | const { alertToast } = useToast(); 9 | 10 | return useQuery( 11 | [documentType], 12 | () => fetchDataFromPath(`${REACT_APP_API_URL}/user-document/${documentType}`), 13 | { 14 | suspense: true, 15 | onError: () => { 16 | alertToast('WARNING', '문서 목록을 가져오지 못했습니다. 다시 시도해주세요.'); 17 | } 18 | } 19 | ); 20 | }; 21 | 22 | export default useGetDocumentQuery; 23 | -------------------------------------------------------------------------------- /frontend/src/query/document/useRemoveDocumentBookmarkMutation.tsx: -------------------------------------------------------------------------------- 1 | import { useQueryClient, useMutation } from 'react-query'; 2 | import useToast from '../../hooks/useToast'; 3 | 4 | const { REACT_APP_API_URL } = process.env; 5 | 6 | const useRemoveDocumentBookmarkMutation = (documentType: string) => { 7 | const { alertToast } = useToast(); 8 | const queryClient = useQueryClient(); 9 | 10 | return useMutation((id: string) => { 11 | return fetch(`${REACT_APP_API_URL}/user-document/${id}/unbookmark`, { 12 | method: 'POST', 13 | headers: { 14 | 'Content-Type': 'application/json' 15 | }, 16 | credentials: 'include' 17 | }); 18 | }, 19 | { 20 | onSuccess: () => { 21 | queryClient.invalidateQueries(documentType); 22 | alertToast('INFO', '북마크를 해제했습니다.'); 23 | }, 24 | onError: () => { 25 | alertToast('WARNING', '북마크 해제에 실패했습니다. 다시 시도해주세요.'); 26 | } 27 | } 28 | ); 29 | }; 30 | 31 | export default useRemoveDocumentBookmarkMutation; 32 | -------------------------------------------------------------------------------- /frontend/src/query/profile/useGetProfileQuery.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from 'react-query'; 2 | import { fetchDataFromPath } from '../../utils/fetchBeforeRender'; 3 | 4 | const { REACT_APP_API_URL } = process.env; 5 | 6 | const useGetProfileQuery = () => { 7 | return useQuery( 8 | ['userProfile'], 9 | () => fetchDataFromPath(`${REACT_APP_API_URL}/user/profile`), 10 | { 11 | cacheTime: Infinity, 12 | suspense: true, 13 | } 14 | ); 15 | }; 16 | 17 | export default useGetProfileQuery; 18 | -------------------------------------------------------------------------------- /frontend/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /frontend/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import { server } from './mocks/server'; 3 | 4 | beforeAll(() => server.listen()); 5 | afterEach(() => server.resetHandlers()); 6 | afterAll(() => server.close()); 7 | -------------------------------------------------------------------------------- /frontend/src/theme.ts: -------------------------------------------------------------------------------- 1 | const light = { 2 | primary: '#3A7DFF', 3 | text: '#222222', 4 | background: '#FEFEFF', 5 | reverseText: '#FEFEFF', 6 | reverseBackground: '#222222', 7 | border: '#BBBBBB', 8 | caution: '#FF5757', 9 | white: '#FFFFFF', 10 | gray: '#A5A5A5', 11 | black: '#222222', 12 | interaction: '#222222', 13 | defaultShadow: 'rgba(0, 0, 0, 0.25)', 14 | }; 15 | 16 | const dark = { 17 | primary: '#3A7DFF', 18 | text: '#ECECEC', 19 | background: '#252525', 20 | reverseText: '#252525', 21 | reverseBackground: '#ECECEC', 22 | border: '#666666', 23 | caution: '#FF5757', 24 | white: '#FFFFFF', 25 | gray: '#808080', 26 | black: '#222222', 27 | interaction: '#FFFFFF', 28 | defaultShadow: 'rgba(0, 0, 0, 0.25)', 29 | }; 30 | 31 | export { light, dark }; 32 | -------------------------------------------------------------------------------- /frontend/src/types/crdt.d.ts: -------------------------------------------------------------------------------- 1 | declare type CRDTIndex = number[]; 2 | 3 | declare type Char = { 4 | id: string, 5 | leftId: string, 6 | rightId: string, 7 | siteId: string, 8 | value: string, 9 | tombstone: boolean, 10 | } 11 | 12 | declare type CharMap = { 13 | [key: string]: Char, 14 | } 15 | 16 | declare type CRDT = { 17 | siteId: string, 18 | head: Char, 19 | tail: Char, 20 | charMap: CharMap, 21 | } 22 | -------------------------------------------------------------------------------- /frontend/src/types/document.d.ts: -------------------------------------------------------------------------------- 1 | declare type DocListItem = { 2 | id: string; 3 | title: string; 4 | lastVisited: string; 5 | role: string; 6 | createdAt: string; 7 | isBookmarked: boolean; 8 | }; 9 | -------------------------------------------------------------------------------- /frontend/src/types/eventHandler.d.ts: -------------------------------------------------------------------------------- 1 | declare type WrappedHandler = () => void; 2 | 3 | declare type MutateProp = (string: id) => void; 4 | -------------------------------------------------------------------------------- /frontend/src/types/onlineUser.d.ts: -------------------------------------------------------------------------------- 1 | declare interface OnlineUserType { 2 | id: string; 3 | name: string; 4 | color: string; 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/types/style.d.ts: -------------------------------------------------------------------------------- 1 | import 'styled-components'; 2 | 3 | declare module 'styled-components' { 4 | export interface DefaultTheme { 5 | primary: string, 6 | text: string, 7 | background: string, 8 | reverseText: string, 9 | reverseBackground: string, 10 | border: string, 11 | caution: string, 12 | white: string, 13 | gray: string, 14 | black: string, 15 | interaction: string, 16 | defaultShadow: string, 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /frontend/src/types/userProfile.d.ts: -------------------------------------------------------------------------------- 1 | declare type UserProfile = { 2 | profileURL: string; 3 | name: string; 4 | }; 5 | -------------------------------------------------------------------------------- /frontend/src/utils/fetchBeforeRender.ts: -------------------------------------------------------------------------------- 1 | import { worker } from '../mocks/worker'; 2 | const { REACT_APP_NODE_ENV, REACT_APP_API_URL } = process.env; 3 | 4 | if (REACT_APP_NODE_ENV === 'mock') { 5 | worker.start({ 6 | onUnhandledRequest: 'bypass' 7 | }); 8 | } 9 | 10 | const fetchDataFromPath = (path: string) => { 11 | try { 12 | const data = fetch(`${path}`, { 13 | method: 'GET', 14 | headers: { 15 | 'Content-Type': 'application/json' 16 | }, 17 | credentials: 'include' 18 | }).then((response) => response.json()); 19 | return data; 20 | } catch (err) { 21 | throw new Error('Fail to fetch Data! Please report it to our GitHub.'); 22 | } 23 | }; 24 | 25 | export { fetchDataFromPath }; 26 | -------------------------------------------------------------------------------- /frontend/src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | const copyURLPath = () => { 2 | const url = window.location.href; 3 | return navigator.clipboard.writeText(url); 4 | }; 5 | 6 | const getRandomColor = () => '#' + Math.floor(Math.random() * 16777215).toString(16); 7 | 8 | export { copyURLPath, getRandomColor }; 9 | 10 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "codocs", 3 | "version": "1.0.0", 4 | "description": "함께 🤝, 코드 👨‍💻, 문서 📝를 작성하세요. Codocs.", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "pm2 start ecosystem.config.js" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/boostcampwm-2022/web16-Codocs.git" 13 | }, 14 | "keywords": [], 15 | "author": "", 16 | "license": "ISC", 17 | "bugs": { 18 | "url": "https://github.com/boostcampwm-2022/web16-Codocs/issues" 19 | }, 20 | "homepage": "https://github.com/boostcampwm-2022/web16-Codocs#readme", 21 | "devDependencies": { 22 | "pm2": "^5.2.2" 23 | } 24 | } 25 | --------------------------------------------------------------------------------