├── .DS_Store ├── .github ├── ISSUE_TEMPLATE │ ├── SPRINT_ISSUE.md │ └── custom.md └── workflows │ ├── check-build.yml │ └── deploy.yml ├── README.md ├── backend ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── README.md ├── nest-cli.json ├── package-lock.json ├── package.json ├── pm2-config.json ├── src │ ├── app.module.ts │ ├── app.service.ts │ ├── auth │ │ ├── auth.controller.ts │ │ ├── auth.module.ts │ │ ├── filter │ │ │ └── unauth-redirect.filter.ts │ │ ├── guard │ │ │ ├── github.guard.ts │ │ │ └── session.guard.ts │ │ └── strategy │ │ │ └── github.strategy.ts │ ├── main.ts │ ├── middlewares │ │ ├── logger.middleware.ts │ │ └── session.middleware.ts │ ├── migrations │ │ ├── 1669037051304-add-thumbnail-on-workspace.ts │ │ ├── 1669275086576-create-object-table.ts │ │ ├── 1669621214094-change-object-columns.ts │ │ ├── 1669639570300-change-role-type.ts │ │ └── 1669751169830-change-object-columns-scale.ts │ ├── object-database │ │ ├── abstract │ │ │ ├── object-database.interface.ts │ │ │ ├── object-handler.abstract.ts │ │ │ └── workspace-object.interface.ts │ │ ├── dto │ │ │ ├── create-object.dto.ts │ │ │ ├── create-table-request.dto.ts │ │ │ ├── select-object.dto.ts │ │ │ └── update-object.dto.ts │ │ ├── entity │ │ │ └── workspace-object.entity.ts │ │ ├── mongoose-object-handler.service.ts │ │ ├── object-database.controller.ts │ │ ├── object-database.module.ts │ │ ├── object-handler.service.ts │ │ └── schema │ │ │ ├── object-bucket.schema.ts │ │ │ ├── template-bucket.schema.ts │ │ │ └── workspace-object.schema.ts │ ├── ormconfig.ts │ ├── socket │ │ ├── adapter │ │ │ └── custom-socket.adapter.ts │ │ ├── db-access.service.ts │ │ ├── dto │ │ │ ├── change-role.dto.ts │ │ │ ├── object-map.vo.ts │ │ │ ├── object-move.dto.ts │ │ │ ├── object-scale.dto.ts │ │ │ ├── user-map.vo.ts │ │ │ └── user.dao.ts │ │ ├── guard │ │ │ └── user-role.guard.ts │ │ ├── object-management.service.ts │ │ ├── pipe │ │ │ ├── object-transform.pipe.ts │ │ │ └── object-updating.pipe.ts │ │ ├── socket.gateway.ts │ │ ├── socket.module.ts │ │ └── user-management.service.ts │ ├── team │ │ ├── dto │ │ │ └── team.dto.ts │ │ ├── entity │ │ │ ├── team-member.entity.ts │ │ │ └── team.entity.ts │ │ ├── enum │ │ │ └── is-team.enum.ts │ │ ├── team.controller.ts │ │ ├── team.module.ts │ │ └── team.service.ts │ ├── types │ │ ├── auth.d.ts │ │ ├── object.d.ts │ │ ├── session.d.ts │ │ ├── socket.d.ts │ │ └── workspace.d.ts │ ├── user │ │ ├── dto │ │ │ ├── partial-search.dto.ts │ │ │ └── user.dto.ts │ │ ├── entity │ │ │ └── user.entity.ts │ │ ├── pipe │ │ │ └── part-search-transform.pipe.ts │ │ ├── user.controller.ts │ │ ├── user.module.ts │ │ ├── user.service.spec.ts │ │ └── user.service.ts │ ├── util │ │ └── constant │ │ │ └── role.constant.ts │ └── workspace │ │ ├── dto │ │ ├── workspace.dto.ts │ │ ├── workspaceCreateRequest.dto.ts │ │ ├── workspaceId.dto.ts │ │ └── workspaceMetadata.dto.ts │ │ ├── entity │ │ ├── workspace-member.entity.ts │ │ └── workspace.entity.ts │ │ ├── file-interceptor │ │ └── thumbnail.interceptor.ts │ │ ├── workspace.controller.ts │ │ ├── workspace.module.ts │ │ └── workspace.service.ts ├── test │ ├── app.e2e-spec.ts │ └── jest-e2e.json ├── tsconfig.build.json └── tsconfig.json ├── frontend ├── .eslintrc ├── .gitignore ├── .prettierrc ├── README.md ├── craco.config.js ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo.png │ ├── manifest.json │ └── robots.txt ├── src │ ├── App.tsx │ ├── api │ │ ├── user.ts │ │ ├── user.types.ts │ │ ├── workspace.ts │ │ └── workspace.type.ts │ ├── assets │ │ ├── gif │ │ │ └── spinner.gif │ │ ├── icon │ │ │ ├── boo-crum.png │ │ │ ├── close.svg │ │ │ ├── copy-link.svg │ │ │ ├── cursor.cur │ │ │ ├── dropdown-active.svg │ │ │ ├── dropdown-color.svg │ │ │ ├── dropdown-inactive.svg │ │ │ ├── export.png │ │ │ ├── github-login.svg │ │ │ ├── logout.svg │ │ │ ├── plus-workspace.svg │ │ │ ├── rename-section.svg │ │ │ ├── share.svg │ │ │ ├── toolkit-move-cursor.svg │ │ │ ├── toolkit-select-cursor.svg │ │ │ ├── user-icon.svg │ │ │ ├── user-profile.svg │ │ │ ├── zoom-in.svg │ │ │ └── zoom-out.svg │ │ ├── image │ │ │ ├── eraser.png │ │ │ ├── error-image.png │ │ │ ├── hero-image.png │ │ │ ├── pen.png │ │ │ ├── post-it.svg │ │ │ └── section.svg │ │ └── index.ts │ ├── components │ │ ├── context-menu │ │ │ ├── index.style.tsx │ │ │ ├── index.tsx │ │ │ └── index.types.ts │ │ ├── error-modal │ │ │ ├── index.style.ts │ │ │ └── index.tsx │ │ ├── github-login-button │ │ │ ├── index.style.tsx │ │ │ └── index.tsx │ │ ├── loading │ │ │ ├── index.style.tsx │ │ │ └── index.tsx │ │ ├── logo │ │ │ ├── index.style.tsx │ │ │ └── index.tsx │ │ ├── modal │ │ │ ├── index.style.tsx │ │ │ ├── index.tsx │ │ │ └── index.types.ts │ │ ├── protected-route │ │ │ └── index.tsx │ │ ├── toast-message │ │ │ ├── index.style.tsx │ │ │ └── index.tsx │ │ └── user-profile │ │ │ ├── index.style.tsx │ │ │ └── index.tsx │ ├── context │ │ ├── main-workspace.ts │ │ ├── user.ts │ │ └── workspace.ts │ ├── data │ │ ├── workspace-object-color.ts │ │ ├── workspace-order.ts │ │ ├── workspace-role.ts │ │ ├── workspace-sidebar.tsx │ │ └── workspace-tool.ts │ ├── global.style.tsx │ ├── hooks │ │ ├── useAuth.ts │ │ ├── useContextMenu.tsx │ │ └── useModal.tsx │ ├── index.tsx │ ├── pages │ │ ├── error │ │ │ ├── index.style.tsx │ │ │ └── index.tsx │ │ ├── login │ │ │ ├── hero-content │ │ │ │ ├── index.style.tsx │ │ │ │ └── index.tsx │ │ │ ├── hero-image │ │ │ │ ├── index.style.tsx │ │ │ │ └── index.tsx │ │ │ ├── index.style.tsx │ │ │ ├── index.tsx │ │ │ └── login-content │ │ │ │ ├── index.style.tsx │ │ │ │ └── index.tsx │ │ ├── main │ │ │ ├── all-workspace │ │ │ │ └── index.tsx │ │ │ ├── contents │ │ │ │ ├── index.style.tsx │ │ │ │ └── index.tsx │ │ │ ├── delete-modal │ │ │ │ ├── index.style.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── index.types.ts │ │ │ ├── header │ │ │ │ ├── index.style.tsx │ │ │ │ └── index.tsx │ │ │ ├── index.style.tsx │ │ │ ├── index.tsx │ │ │ ├── order-dropdown │ │ │ │ ├── index.style.tsx │ │ │ │ └── index.tsx │ │ │ ├── recent-workspace │ │ │ │ └── index.tsx │ │ │ ├── rename-modal │ │ │ │ ├── index.style.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── index.types.ts │ │ │ ├── sidebar │ │ │ │ ├── index.style.tsx │ │ │ │ └── index.tsx │ │ │ ├── workspace-card │ │ │ │ ├── index.style.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── index.type.ts │ │ │ ├── workspace-list │ │ │ │ ├── index.style.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── index.type.ts │ │ │ ├── workspace-menu │ │ │ │ ├── index.style.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── index.types.ts │ │ │ ├── workspace-template-list │ │ │ │ ├── index.style.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── index.type.ts │ │ │ └── workspace-template │ │ │ │ ├── index.style.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── index.type.ts │ │ └── workspace │ │ │ ├── current-user-list │ │ │ ├── index.style.tsx │ │ │ └── index.tsx │ │ │ ├── export-modal │ │ │ ├── index.style.tsx │ │ │ └── index.tsx │ │ │ ├── header │ │ │ ├── index.style.tsx │ │ │ ├── index.tsx │ │ │ └── index.type.ts │ │ │ ├── index.tsx │ │ │ ├── layout │ │ │ └── index.tsx │ │ │ ├── left-side │ │ │ ├── index.style.tsx │ │ │ └── index.tsx │ │ │ ├── member-role │ │ │ ├── index.style.tsx │ │ │ ├── index.tsx │ │ │ └── index.type.ts │ │ │ ├── object-edit-menu │ │ │ ├── index.style.tsx │ │ │ ├── index.tsx │ │ │ └── index.type.ts │ │ │ ├── pen-type-box │ │ │ ├── index.style.tsx │ │ │ └── index.tsx │ │ │ ├── right-side │ │ │ ├── index.style.tsx │ │ │ └── index.tsx │ │ │ ├── share-modal │ │ │ ├── index.style.tsx │ │ │ ├── index.tsx │ │ │ └── index.type.ts │ │ │ ├── toolkit │ │ │ ├── index.style.tsx │ │ │ └── index.tsx │ │ │ ├── whiteboard-canvas │ │ │ ├── index.style.tsx │ │ │ ├── index.tsx │ │ │ ├── types │ │ │ │ ├── fabric-options.ts │ │ │ │ ├── index.ts │ │ │ │ ├── offscreencanvas.types.ts │ │ │ │ ├── socket.types.ts │ │ │ │ ├── workspace-member.types.ts │ │ │ │ ├── workspace-object.types.ts │ │ │ │ └── workspace.types.ts │ │ │ ├── useCanvas.ts │ │ │ ├── useCanvasToSocket.ts │ │ │ ├── useCursorWorker.ts │ │ │ ├── useEditMenu.ts │ │ │ ├── useObjectWorker.ts │ │ │ ├── useOffscreencanvas.ts │ │ │ ├── useRoleEvent.ts │ │ │ └── useSocket.ts │ │ │ └── zoom-controller │ │ │ ├── index.style.tsx │ │ │ └── index.tsx │ ├── react-app-env.d.ts │ ├── reportWebVitals.ts │ ├── setupTests.ts │ ├── utils │ │ ├── convert-time.utils.ts │ │ ├── fabric.utils.ts │ │ ├── is-selected-cursor.utils.ts │ │ ├── member-role.utils.ts │ │ ├── object-from-server.ts │ │ ├── object-to-server.ts │ │ ├── object.utils.ts │ │ └── type.utils.ts │ └── worker │ │ ├── cursor.worker.ts │ │ ├── object.worker.ts │ │ └── offcanvas.worker.ts └── tsconfig.json ├── package-lock.json └── util ├── .DS_Store ├── .gitignore ├── .gitmessage.txt ├── .husky └── commit-msg ├── commitlint.config.js ├── package-lock.json └── package.json /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web22-BooCrum/99ea3a697c4db9a38a0e049a0b59df9c395c1b79/.DS_Store -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/SPRINT_ISSUE.md: -------------------------------------------------------------------------------- 1 | 제목 : 스프린트 백로그 단위 2 | 레이블 : 도메인 3 | 4 | 본문 : 5 | 6 | ## product backlog 7 | 8 | ## description 9 | 기능 설명 10 | 11 | ## Feature 12 | - [ ] Layout 13 | - [ ] State 14 | ... 15 | 16 | 17 | 18 | * 담당자가 이슈 직접 작성 -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue template 3 | about: Describe this issue template's purpose here. 4 | title: FE/BE - Issue title... 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Product backlog 11 | 12 | ## Description 13 | 14 | ## Feature 15 | - [ ] 16 | -------------------------------------------------------------------------------- /.github/workflows/check-build.yml: -------------------------------------------------------------------------------- 1 | name: Check Build Possibility 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - dev 7 | 8 | jobs: 9 | try-frontend-build: 10 | runs-on: ubuntu-20.04 11 | env: 12 | DISABLE_ESLINT_PLUGIN: true 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/setup-node@v3.5.1 16 | with: 17 | node-version: "16.18.0" 18 | - name: try build frontend 19 | run: | 20 | cd ./frontend 21 | npm install 22 | npm run build 23 | try-backend-build: 24 | runs-on: ubuntu-20.04 25 | env: 26 | MYSQL_HOST: ${{ secrets.MYSQL_HOST }} 27 | MYSQL_PORT: ${{ secrets.MYSQL_PORT }} 28 | MYSQL_USERNAME: ${{ secrets.MYSQL_USERNAME }} 29 | MYSQL_PASSWORD: ${{ secrets.MYSQL_PASSWORD }} 30 | MYSQL_DATABASE: ${{ secrets.MYSQL_DATABASE }} 31 | steps: 32 | - uses: actions/checkout@v3 33 | - uses: actions/setup-node@v3.5.1 34 | with: 35 | node-version: "16.18.0" 36 | - name: try build backend 37 | run: | 38 | echo $MYSQL_HOST 39 | cd ./backend 40 | npm install 41 | npm run build 42 | npm test --detectOpenHandles --forceExit 43 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Try Deploy 2 | on: 3 | push: 4 | branches: 5 | - dev 6 | workflow_dispatch: 7 | concurrency: 8 | # 그룹을 pr의 head_ref로 정의 9 | group: 'try-deploy' 10 | # 해당 pr에서 새로운 워크플로우가 실행될 경우, 이전에 워크플로우가 있다면 이전 워크플로우를 취소하도록 한다. 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | try-deploy: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: deploy 18 | uses: appleboy/ssh-action@v0.1.5 19 | with: 20 | host: ${{ secrets.SERVER_HOST }} 21 | port: ${{ secrets.SERVER_SSH_PORT }} 22 | username: ${{ secrets.SERVER_USERNAME }} 23 | password: ${{ secrets.SERVER_PASSWORD }} 24 | script: | 25 | export GIT_PROJECT_PATH=https://github.com/boostcampwm-2022/web22-BooCrum.git 26 | export PROJECT_DIR_NAME=BooCrum 27 | export BRANCH_NAME=dev 28 | export FRONTEND_WORKING_DIR=frontend 29 | export BACKEND_WORKING_DIR=backend 30 | sh ~/scripts/deploy.sh 31 | -------------------------------------------------------------------------------- /backend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | 'prettier/prettier': [ 25 | 'error', 26 | { 27 | endOfLine: 'auto', 28 | }, 29 | ], 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /backend/.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 36 | 37 | .env -------------------------------------------------------------------------------- /backend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "printWidth": 120 5 | } 6 | -------------------------------------------------------------------------------- /backend/README.md: -------------------------------------------------------------------------------- 1 | ## .env 양식 2 | 3 | ``` 4 | MYSQL_HOST= 5 | MYSQL_PORT= 6 | MYSQL_USERNAME= 7 | MYSQL_PASSWORD= 8 | MYSQL_DATABASE= 9 | SESSION_SECRET= 10 | 11 | GITHUB_CLIENT_ID= 12 | GITHUB_CLIENT_SECRET= 13 | 14 | NODE_ENV=develop|production 15 | 16 | REDIRECT_AFTER_LOGIN= 17 | REDIRECT_FAIL_LOGIN= 18 | ``` 19 | 20 | ## migration 실행 시 21 | 22 | ``` 23 | // 마이그레이션 반영 목록 확인 24 | npm run typeorm -- migration:show -d src/ormconfig.ts 25 | // 마이그레이션 전체 적용 26 | npm run typeorm -- migration:run -d src/ormconfig.ts 27 | // 가장 최근에 적용된 마이그레이션 1개 파일 되돌리기 28 | npm run typeorm -- migration:revert -d src/ormconfig.ts 29 | ``` 30 | 31 | ## Description 32 | 33 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. 34 | 35 | ## Installation 36 | 37 | ```bash 38 | $ npm install 39 | ``` 40 | 41 | ## Running the app 42 | 43 | ```bash 44 | # development 45 | $ npm run start 46 | 47 | # watch mode 48 | $ npm run start:dev 49 | 50 | # production mode 51 | $ npm run start:prod 52 | ``` 53 | 54 | ## Test 55 | 56 | ```bash 57 | # unit tests 58 | $ npm run test 59 | 60 | # e2e tests 61 | $ npm run test:e2e 62 | 63 | # test coverage 64 | $ npm run test:cov 65 | ``` 66 | 67 | ## Support 68 | 69 | Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). 70 | 71 | ## Stay in touch 72 | 73 | - Author - [Kamil Myśliwiec](https://kamilmysliwiec.com) 74 | - Website - [https://nestjs.com](https://nestjs.com/) 75 | - Twitter - [@nestframework](https://twitter.com/nestframework) 76 | 77 | ## License 78 | 79 | Nest is [MIT licensed](LICENSE). 80 | -------------------------------------------------------------------------------- /backend/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src" 5 | } 6 | -------------------------------------------------------------------------------- /backend/pm2-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "apps": [ 3 | { 4 | "name": "BooCrum", 5 | "script": "./dist/main.js", 6 | "env": { 7 | "API_PORT": 3000, 8 | "WS_PORT": 8080 9 | } 10 | }, 11 | { 12 | "name": "BooCrum", 13 | "script": "./dist/main.js", 14 | "env": { 15 | "API_PORT": 3001, 16 | "WS_PORT": 8081 17 | } 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /backend/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { MongooseModule } from '@nestjs/mongoose'; 5 | import { LoggerMiddleware } from './middlewares/logger.middleware'; 6 | import { AuthModule } from './auth/auth.module'; 7 | import { UserModule } from './user/user.module'; 8 | import { TeamModule } from './team/team.module'; 9 | import { WorkspaceModule } from './workspace/workspace.module'; 10 | import { ObjectDatabaseModule } from './object-database/object-database.module'; 11 | import { config } from './ormconfig'; 12 | import { SocketModule } from './socket/socket.module'; 13 | 14 | @Module({ 15 | imports: [ 16 | ConfigModule.forRoot({ isGlobal: true }), 17 | TypeOrmModule.forRoot(config), 18 | MongooseModule.forRoot(process.env.MONGODB_URL), 19 | UserModule, 20 | TeamModule, 21 | WorkspaceModule, 22 | AuthModule, 23 | ObjectDatabaseModule, 24 | SocketModule, 25 | ], 26 | controllers: [], 27 | providers: [], 28 | }) 29 | export class AppModule implements NestModule { 30 | configure(consumer: MiddlewareConsumer) { 31 | consumer.apply(LoggerMiddleware).forRoutes('*'); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /backend/src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpService } from '@nestjs/axios'; 2 | import { Injectable } from '@nestjs/common'; 3 | import { AxiosResponse } from 'axios'; 4 | 5 | @Injectable() 6 | export class AppService { 7 | constructor(private readonly httpService: HttpService) {} 8 | 9 | async getAllObjects(workspaceId: string) { 10 | const requestURL = `${process.env.API_ADDRESS}/object-database/${workspaceId}/object`; 11 | return await this.requestAPI(requestURL, 'GET'); 12 | } 13 | 14 | async isExistWorkspace(workspaceId: string) { 15 | try { 16 | const requestURL = `${process.env.API_ADDRESS}/workspace/${workspaceId}/info/metadata`; 17 | const response = await this.requestAPI(requestURL, 'GET'); 18 | if (response) return true; 19 | } catch (e) { 20 | return false; 21 | } 22 | } 23 | 24 | async getUserRole(workspaceId: string, userId: string) { 25 | const requestURL = `${process.env.API_ADDRESS}/workspace/${workspaceId}/role/${userId}`; 26 | return await this.requestAPI(requestURL, 'GET'); 27 | } 28 | 29 | async requestAPI(requestURL: string, method: 'GET' | 'POST' | 'PATCH' | 'DELETE', body?: object) { 30 | const headers = { accept: 'application/json' }; 31 | 32 | let response: AxiosResponse; 33 | 34 | switch (method) { 35 | case 'GET': 36 | response = await this.httpService.axiosRef.get(requestURL, { headers }); 37 | return response.data; 38 | case 'POST': 39 | response = await this.httpService.axiosRef.post(requestURL, body, { headers }); 40 | return response.data; 41 | case 'PATCH': 42 | response = await this.httpService.axiosRef.patch(requestURL, body, { headers }); 43 | return response.data; 44 | case 'DELETE': 45 | response = await this.httpService.axiosRef.delete(requestURL, { headers }); 46 | return response.data; 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /backend/src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-function */ 2 | import { Controller, UseGuards, UseFilters, Get, Put, Req, Res, Session, UnauthorizedException } from '@nestjs/common'; 3 | import { Request, Response } from 'express'; 4 | import { UnAuthRedirectionFilter } from './filter/unauth-redirect.filter'; 5 | import { GithubOAuthGuard } from './guard/github.guard'; 6 | import { AuthorizationGuard, UnAuthorizationGuard } from './guard/session.guard'; 7 | 8 | @Controller('auth') 9 | export class AuthController { 10 | constructor() {} 11 | 12 | @UseGuards(GithubOAuthGuard) 13 | @UseGuards(UnAuthorizationGuard) 14 | @UseFilters(UnAuthRedirectionFilter) 15 | @Get('/oauth/github') 16 | startGithubOAuthProcess() {} 17 | 18 | @UseGuards(GithubOAuthGuard) 19 | @UseGuards(UnAuthorizationGuard) 20 | @UseFilters(UnAuthRedirectionFilter) 21 | @Get('/oauth/github_callback') 22 | handleGithubData(@Req() req: Request, @Session() session: Record, @Res() res: Response): void { 23 | session.user = req.user; 24 | res.redirect(process.env.REDIRECT_AFTER_LOGIN); 25 | } 26 | 27 | @UseGuards(AuthorizationGuard) 28 | @Put('/logout') 29 | destroySession(@Req() req: Request): void { 30 | req.session.destroy(() => {}); 31 | } 32 | 33 | @Get('/status') 34 | checkLoginStatus(@Session() session: Record, @Res() res: Response): void { 35 | if (!session.user) throw new UnauthorizedException(); 36 | res.sendStatus(200); 37 | } 38 | 39 | // @Get('/info/:sessionId') 40 | // async getSessionData(@Param('sessionId') sessionId: string) { 41 | // const { data } = (await this.authService.getSessionData(sessionId))[0]; 42 | // const { user } = JSON.parse(data); 43 | // return user.userId; 44 | // } 45 | } 46 | -------------------------------------------------------------------------------- /backend/src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AuthController } from './auth.controller'; 3 | import { GithubStrategy } from './strategy/github.strategy'; 4 | import { UserModule } from '../user/user.module'; 5 | import { UserService } from '../user/user.service'; 6 | import { TypeOrmModule } from '@nestjs/typeorm'; 7 | import { User } from '../user/entity/user.entity'; 8 | import { TeamModule } from '../team/team.module'; 9 | import { TeamService } from '../team/team.service'; 10 | import { Team } from '../team/entity/team.entity'; 11 | import { TeamMember } from '../team/entity/team-member.entity'; 12 | 13 | @Module({ 14 | imports: [UserModule, TeamModule, TypeOrmModule.forFeature([User, Team, TeamMember])], 15 | controllers: [AuthController], 16 | providers: [GithubStrategy, UserService, TeamService], 17 | }) 18 | export class AuthModule {} 19 | -------------------------------------------------------------------------------- /backend/src/auth/filter/unauth-redirect.filter.ts: -------------------------------------------------------------------------------- 1 | import { ExceptionFilter, Catch, UnauthorizedException, ArgumentsHost, HttpException } from '@nestjs/common'; 2 | import { Response } from 'express'; 3 | 4 | @Catch(UnauthorizedException) 5 | export class UnAuthRedirectionFilter implements ExceptionFilter { 6 | catch(exception: HttpException, host: ArgumentsHost) { 7 | const context = host.switchToHttp(); 8 | const res = context.getResponse(); 9 | const statusCode = exception.getStatus(); 10 | res.status(statusCode).redirect(process.env.REDIRECT_FAIL_LOGIN); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /backend/src/auth/guard/github.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class GithubOAuthGuard extends AuthGuard('github') {} 6 | -------------------------------------------------------------------------------- /backend/src/auth/guard/session.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { Observable } from 'rxjs'; 3 | 4 | /** 5 | * 로그인한 사용자만 통과시키는 가드입니다. 6 | * 가드의 기준은 "Session에 user 데이터가 존재하는가?" 입니다. 7 | */ 8 | @Injectable() 9 | export class AuthorizationGuard implements CanActivate { 10 | canActivate(context: ExecutionContext): boolean | Promise | Observable { 11 | const req = context.switchToHttp().getRequest(); 12 | return this.validateRequest(req); 13 | } 14 | 15 | private validateRequest(req: any) { 16 | const session: Record = req.session; 17 | if (!session.user) throw new UnauthorizedException('비로그인 상태입니다.'); 18 | return true; 19 | } 20 | } 21 | 22 | /** 23 | * 로그인하지 않은 사용자만 통과시키는 가드입니다. 24 | * 가드의 기준은 "Session에 user 데이터가 존재하는가?" 입니다. 25 | * 중복 로그인을 방지하기 위한 장치입니다. 26 | */ 27 | @Injectable() 28 | export class UnAuthorizationGuard implements CanActivate { 29 | canActivate(context: ExecutionContext): boolean | Promise | Observable { 30 | const req = context.switchToHttp().getRequest(); 31 | const res = context.switchToHttp().getResponse(); 32 | return this.validateRequest(req, res); 33 | } 34 | 35 | private validateRequest(req: any, res: any) { 36 | const session: Record = req.session; 37 | if (session.user) { 38 | return res.redirect('/'); 39 | } 40 | return true; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /backend/src/auth/strategy/github.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Strategy, StrategyOptions, Profile } from 'passport-github'; 4 | import { TeamService } from '../../team/team.service'; 5 | import { UserDto } from '../../user/dto/user.dto'; 6 | import { UserService } from '../../user/user.service'; 7 | 8 | @Injectable() 9 | export class GithubStrategy extends PassportStrategy(Strategy, 'github') { 10 | constructor(private userService: UserService, private teamService: TeamService) { 11 | const options: StrategyOptions = { 12 | clientID: process.env.GITHUB_CLIENT_ID, 13 | clientSecret: process.env.GITHUB_CLIENT_SECRET, 14 | }; 15 | super(options); 16 | } 17 | 18 | async validate(_accessToken: string, _refreshToken: string, profile: Profile): Promise { 19 | const userData: UserDto = { 20 | userId: profile.id, 21 | nickname: profile.username, 22 | }; 23 | const user = await this.userService.createOrFindUser(userData); 24 | const ret = { 25 | ...user, 26 | registerDate: (user.registerDate as unknown as Date).toISOString(), 27 | userTeamId: (await this.teamService.findUserTeam(user.userId)).teamId, 28 | }; 29 | return ret; // req.user에 담기는 정보 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /backend/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { NestExpressApplication } from '@nestjs/platform-express'; 3 | import { AppModule } from './app.module'; 4 | import { createSessionMiddleware } from './middlewares/session.middleware'; 5 | import { RedisIoAdapter } from './socket/adapter/custom-socket.adapter'; 6 | 7 | async function bootstrap() { 8 | const apiPort = parseInt(process.env.API_PORT); 9 | const wsPort = parseInt(process.env.WS_PORT); 10 | // 서버 초기화 11 | const app = await NestFactory.create(AppModule); 12 | const redisIoAdapter = new RedisIoAdapter(app, wsPort); 13 | 14 | await redisIoAdapter.connectToRedis(); 15 | app.useWebSocketAdapter(redisIoAdapter); 16 | 17 | // 세션 초기화 18 | app.use(createSessionMiddleware()); 19 | 20 | app.setGlobalPrefix('api'); 21 | await app.listen(apiPort); 22 | console.log(`API Server Listen: ${apiPort}\nWs Server Listen: ${wsPort}`); 23 | } 24 | bootstrap(); 25 | -------------------------------------------------------------------------------- /backend/src/middlewares/logger.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger, NestMiddleware } from '@nestjs/common'; 2 | import { Request, Response, NextFunction } from 'express'; 3 | 4 | @Injectable() 5 | export class LoggerMiddleware implements NestMiddleware { 6 | private logger = new Logger('HTTP'); 7 | 8 | use(request: Request, response: Response, next: NextFunction): void { 9 | const { ip, method, originalUrl } = request; 10 | const userAgent = request.get('user-agent') || ''; 11 | 12 | response.on('finish', () => { 13 | const { statusCode } = response; 14 | const contentLength = response.get('content-length'); 15 | this.logger.log(`${method} ${originalUrl} ${statusCode} ${contentLength} - ${userAgent} ${ip}`); 16 | }); 17 | 18 | next(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /backend/src/middlewares/session.middleware.ts: -------------------------------------------------------------------------------- 1 | import * as session from 'express-session'; 2 | import * as MySQLStore from 'express-mysql-session'; 3 | 4 | export function createSessionMiddleware() { 5 | const MySqlStorage = MySQLStore(session); 6 | const sqlStorage = new MySqlStorage({ 7 | host: process.env.MYSQL_HOST, 8 | port: parseInt(process.env.MYSQL_PORT), 9 | user: process.env.MYSQL_USERNAME, 10 | password: process.env.MYSQL_PASSWORD, 11 | database: process.env.MYSQL_DATABASE, 12 | }); 13 | const sessionMiddleware = session({ 14 | secret: process.env.SESSION_SECRET, 15 | resave: false, 16 | saveUninitialized: false, 17 | store: sqlStorage, 18 | cookie: { 19 | maxAge: 6000 * 24 * 365, 20 | httpOnly: true, 21 | }, 22 | }); 23 | return sessionMiddleware; 24 | } 25 | -------------------------------------------------------------------------------- /backend/src/migrations/1669037051304-add-thumbnail-on-workspace.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; 2 | 3 | export class addThumbnailOnWorkspace1669037051304 implements MigrationInterface { 4 | public async up(queryRunner: QueryRunner): Promise { 5 | await queryRunner.addColumn( 6 | 'workspace', 7 | new TableColumn({ 8 | name: 'thumbnail', 9 | type: 'varchar', 10 | length: '2083', 11 | isNullable: true, 12 | }), 13 | ); 14 | } 15 | 16 | public async down(queryRunner: QueryRunner): Promise { 17 | await queryRunner.dropColumn('workspace', 'thumbnail'); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /backend/src/migrations/1669275086576-create-object-table.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm'; 2 | 3 | export class createObjectTable1669275086576 implements MigrationInterface { 4 | name = 'createObjectTable1669275086576'; 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query( 8 | `CREATE TABLE \`workspace_object\` (\`object_id\` varchar(500) NOT NULL, \`type\` varchar(120) NOT NULL, \`x_pos\` int NOT NULL, \`y_pos\` int NOT NULL, \`width\` int NOT NULL, \`height\` int NOT NULL, \`color\` varchar(100) NOT NULL, \`text\` text NOT NULL, \`creator\` varchar(50) NOT NULL, \`workspace_id\` varchar(36) NULL, INDEX \`IDX_c272c6e835aa677e9e8bb1d3c1\` (\`workspace_id\`), PRIMARY KEY (\`object_id\`)) ENGINE=InnoDB`, 9 | ); 10 | await queryRunner.query( 11 | `ALTER TABLE \`workspace_object\` ADD CONSTRAINT \`FK_c272c6e835aa677e9e8bb1d3c16\` FOREIGN KEY (\`workspace_id\`) REFERENCES \`workspace\`(\`workspace_id\`) ON DELETE CASCADE ON UPDATE NO ACTION`, 12 | ); 13 | } 14 | 15 | public async down(queryRunner: QueryRunner): Promise { 16 | await queryRunner.query(`ALTER TABLE \`workspace_object\` DROP FOREIGN KEY \`FK_c272c6e835aa677e9e8bb1d3c16\``); 17 | await queryRunner.query(`DROP INDEX \`IDX_c272c6e835aa677e9e8bb1d3c1\` ON \`workspace_object\``); 18 | await queryRunner.query(`DROP TABLE \`workspace_object\``); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /backend/src/migrations/1669621214094-change-object-columns.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm'; 2 | 3 | export class changeObjectColumns1669621214094 implements MigrationInterface { 4 | public async up(queryRunner: QueryRunner): Promise { 5 | await queryRunner.query(`ALTER TABLE \`workspace_object\` CHANGE \`x_pos\` \`left\` int;`); 6 | await queryRunner.query(`ALTER TABLE \`workspace_object\` CHANGE \`y_pos\` \`top\` int;`); 7 | await queryRunner.query(`ALTER TABLE \`workspace_object\` ADD \`font_size\` int NOT NULL`); 8 | } 9 | 10 | public async down(queryRunner: QueryRunner): Promise { 11 | await queryRunner.query(`ALTER TABLE \`workspace_object\` CHANGE \`left\` \`x_pos\` int;`); 12 | await queryRunner.query(`ALTER TABLE \`workspace_object\` CHANGE \`top\` \`y_pos\` int;`); 13 | await queryRunner.query(`ALTER TABLE \`workspace_object\` DROP COLUMN \`font_size\``); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /backend/src/migrations/1669639570300-change-role-type.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm'; 2 | 3 | export class changeRoleType1669639570300 implements MigrationInterface { 4 | public async up(queryRunner: QueryRunner): Promise { 5 | await queryRunner.query( 6 | `ALTER TABLE \`boocrum\`.\`team_member\` CHANGE COLUMN \`role\` \`role\` TINYINT NOT NULL DEFAULT '0'`, 7 | ); 8 | await queryRunner.query( 9 | `ALTER TABLE \`boocrum\`.\`workspace_member\` CHANGE COLUMN \`role\` \`role\` TINYINT NOT NULL DEFAULT '0'`, 10 | ); 11 | } 12 | 13 | public async down(queryRunner: QueryRunner): Promise { 14 | await queryRunner.query(`ALTER TABLE \`boocrum\`.\`team_member\` CHANGE COLUMN \`role\` \`role\` INT NOT NULL`); 15 | await queryRunner.query( 16 | `ALTER TABLE \`boocrum\`.\`workspace_member\` CHANGE COLUMN \`role\` \`role\` INT NOT NULL`, 17 | ); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /backend/src/migrations/1669751169830-change-object-columns-scale.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm'; 2 | 3 | export class changeObjectColumnsScale1669751169830 implements MigrationInterface { 4 | public async up(queryRunner: QueryRunner): Promise { 5 | // INT -> DOUBLE 6 | queryRunner.query(`ALTER TABLE \`workspace_object\` CHANGE COLUMN \`width\` \`width\` DOUBLE NOT NULL`); 7 | queryRunner.query(`ALTER TABLE \`workspace_object\` CHANGE COLUMN \`height\` \`height\` DOUBLE NOT NULL`); 8 | queryRunner.query(`ALTER TABLE \`workspace_object\` CHANGE COLUMN \`left\` \`left\` DOUBLE NOT NULL`); 9 | queryRunner.query(`ALTER TABLE \`workspace_object\` CHANGE COLUMN \`top\` \`top\` DOUBLE NOT NULL`); 10 | // Add Scale Columns 11 | queryRunner.query(`ALTER TABLE \`boocrum\`.\`workspace_object\` ADD COLUMN \`scale_x\` DOUBLE NOT NULL`); 12 | queryRunner.query(`ALTER TABLE \`boocrum\`.\`workspace_object\` ADD COLUMN \`scale_y\` DOUBLE NOT NULL`); 13 | } 14 | 15 | public async down(queryRunner: QueryRunner): Promise { 16 | // DOUBLE -> INT 17 | queryRunner.query(`ALTER TABLE \`workspace_object\` CHANGE COLUMN \`width\` \`width\` INT NOT NULL`); 18 | queryRunner.query(`ALTER TABLE \`workspace_object\` CHANGE COLUMN \`height\` \`height\` INT NOT NULL`); 19 | queryRunner.query(`ALTER TABLE \`workspace_object\` CHANGE COLUMN \`left\` \`left\` INT NOT NULL`); 20 | queryRunner.query(`ALTER TABLE \`workspace_object\` CHANGE COLUMN \`top\` \`top\` INT NOT NULL`); 21 | // Delete Scale Columns 22 | queryRunner.query(`ALTER TABLE \`workspace_object\` DROP COLUMN \`scale_x\``); 23 | queryRunner.query(`ALTER TABLE \`workspace_object\` DROP COLUMN \`scale_y\``); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /backend/src/object-database/abstract/object-database.interface.ts: -------------------------------------------------------------------------------- 1 | import { WorkspaceObjectInterface } from './workspace-object.interface'; 2 | 3 | export interface ObjectDatabaseInterface { 4 | workspaceId: string; 5 | objects: WorkspaceObjectInterface[]; 6 | } 7 | -------------------------------------------------------------------------------- /backend/src/object-database/abstract/object-handler.abstract.ts: -------------------------------------------------------------------------------- 1 | import { CreateObjectDTO } from '../dto/create-object.dto'; 2 | import { UpdateObjectDTO } from '../dto/update-object.dto'; 3 | import { WorkspaceObjectInterface } from './workspace-object.interface'; 4 | 5 | export abstract class AbstractObjectHandlerService { 6 | abstract createObject(workspaceId: string, createObjectDTO: CreateObjectDTO): Promise; 7 | abstract selectAllObjects(workspaceId: string): Promise; 8 | abstract selectObjectById(workspaceId: string, objectId: string): Promise; 9 | abstract updateObject(workspaceId: string, updateObjectDTO: UpdateObjectDTO): Promise; 10 | abstract deleteObject(workspaceId: string, objectId: string): Promise; 11 | } 12 | -------------------------------------------------------------------------------- /backend/src/object-database/abstract/workspace-object.interface.ts: -------------------------------------------------------------------------------- 1 | export interface WorkspaceObjectInterface { 2 | objectId: string; 3 | type: ObjectType; 4 | left: number; 5 | top: number; 6 | width: number; 7 | height: number; 8 | scaleX: number; 9 | scaleY: number; 10 | color: string; 11 | text?: string; 12 | fontSize?: number; 13 | path?: string; 14 | creator: string; 15 | } 16 | -------------------------------------------------------------------------------- /backend/src/object-database/dto/create-object.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsOptional, IsString, IsUUID, IsNotEmpty, ValidateIf, IsDefined } from 'class-validator'; 2 | 3 | export class CreateObjectDTO implements AbstractWorkspaceObject { 4 | @IsUUID() 5 | @IsString() 6 | objectId: string; 7 | 8 | @IsString() 9 | type: ObjectType; 10 | 11 | @IsNumber() 12 | left: number; 13 | 14 | @IsNumber() 15 | top: number; 16 | 17 | @IsNumber() 18 | width: number; 19 | 20 | @IsNumber() 21 | height: number; 22 | 23 | @IsNumber() 24 | scaleX: number; 25 | 26 | @IsNumber() 27 | scaleY: number; 28 | 29 | @IsString() 30 | color: string; 31 | 32 | @ValidateIf((obj) => ['postit', 'section'].indexOf(obj.type) !== -1) 33 | @IsString() 34 | text: string; 35 | 36 | @ValidateIf((obj) => ['postit', 'section'].indexOf(obj.type) !== -1) 37 | @IsNumber() 38 | fontSize: number; 39 | 40 | @ValidateIf((obj) => ['draw'].indexOf(obj.type) !== -1) 41 | @IsString() 42 | path: string; 43 | 44 | @IsString() 45 | @IsOptional() 46 | creator: string; 47 | } 48 | -------------------------------------------------------------------------------- /backend/src/object-database/dto/create-table-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsUUID } from 'class-validator'; 2 | 3 | export class CreateTableRequestDto { 4 | @IsUUID() 5 | workspaceId: string; 6 | } 7 | -------------------------------------------------------------------------------- /backend/src/object-database/dto/select-object.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsOptional, IsString, IsUUID } from 'class-validator'; 2 | 3 | export class SelectObjectDTO { 4 | @IsUUID() 5 | @IsOptional() 6 | workspaceId: string; 7 | 8 | @IsString() 9 | objectId: string; 10 | } 11 | -------------------------------------------------------------------------------- /backend/src/object-database/dto/update-object.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsOptional, IsString } from 'class-validator'; 2 | 3 | export class UpdateObjectDTO implements AbstractPartialWorkspaceObject { 4 | @IsString() 5 | objectId: string; 6 | 7 | @IsString() 8 | @IsOptional() 9 | type: ObjectType; 10 | 11 | @IsNumber() 12 | @IsOptional() 13 | left: number; 14 | 15 | @IsNumber() 16 | @IsOptional() 17 | top: number; 18 | 19 | @IsNumber() 20 | @IsOptional() 21 | width: number; 22 | 23 | @IsNumber() 24 | @IsOptional() 25 | height: number; 26 | 27 | @IsNumber() 28 | @IsOptional() 29 | scaleX: number; 30 | 31 | @IsNumber() 32 | @IsOptional() 33 | scaleY: number; 34 | 35 | @IsString() 36 | @IsOptional() 37 | color: string; 38 | 39 | @IsString() 40 | @IsOptional() 41 | text: string; 42 | 43 | @IsNumber() 44 | @IsOptional() 45 | fontSize: number; 46 | 47 | @IsString() 48 | @IsOptional() 49 | path: string; 50 | } 51 | -------------------------------------------------------------------------------- /backend/src/object-database/entity/workspace-object.entity.ts: -------------------------------------------------------------------------------- 1 | import { Workspace } from '../../workspace/entity/workspace.entity'; 2 | import { Entity, PrimaryColumn, Column, ManyToOne, JoinColumn, Index, Double } from 'typeorm'; 3 | 4 | @Entity({ 5 | name: 'workspace_object', 6 | }) 7 | export class WorkspaceObject { 8 | @PrimaryColumn({ 9 | name: 'object_id', 10 | type: 'varchar', 11 | length: 500, 12 | }) 13 | objectId: string; 14 | 15 | @Column({ 16 | type: 'varchar', 17 | length: '120', 18 | nullable: false, 19 | }) 20 | type: ObjectType; 21 | 22 | @Column({ 23 | name: 'left', 24 | type: 'double', 25 | nullable: false, 26 | }) 27 | left: number; 28 | 29 | @Column({ 30 | name: 'top', 31 | type: 'double', 32 | nullable: false, 33 | }) 34 | top: number; 35 | 36 | @Column({ 37 | type: 'double', 38 | nullable: false, 39 | }) 40 | width: number; 41 | 42 | @Column({ 43 | type: 'double', 44 | nullable: false, 45 | }) 46 | height: number; 47 | 48 | @Column({ 49 | name: 'scale_x', 50 | type: 'double', 51 | nullable: false, 52 | }) 53 | scaleX: number; 54 | 55 | @Column({ 56 | name: 'scale_y', 57 | type: 'double', 58 | nullable: false, 59 | }) 60 | scaleY: number; 61 | 62 | @Column({ 63 | type: 'varchar', 64 | length: '100', 65 | nullable: false, 66 | }) 67 | color: string; 68 | 69 | @Column({ 70 | type: 'text', 71 | }) 72 | text: string; 73 | 74 | @Column({ 75 | name: 'font_size', 76 | type: 'int', 77 | nullable: false, 78 | }) 79 | fontSize: number; 80 | 81 | // 어차피 여기에 저장되는 데이터들은 유저와 Join할 용도는 아님. 82 | // 굳이 제약 조건을 달자고 Foreign Key로 두는 건 좀...? 83 | @Column({ 84 | type: 'varchar', 85 | length: '50', 86 | nullable: false, 87 | }) 88 | creator: string; 89 | 90 | @Index() 91 | @ManyToOne(() => Workspace, (ws) => ws.workspaceObjects, { 92 | onDelete: 'CASCADE', 93 | }) 94 | @JoinColumn({ name: 'workspace_id' }) 95 | workspace: Workspace; 96 | } 97 | -------------------------------------------------------------------------------- /backend/src/object-database/object-database.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | Post, 5 | Delete, 6 | Param, 7 | ValidationPipe, 8 | Body, 9 | Patch, 10 | InternalServerErrorException, 11 | } from '@nestjs/common'; 12 | import { CreateTableRequestDto } from './dto/create-table-request.dto'; 13 | import { ObjectHandlerService } from './object-handler.service'; 14 | import { SelectObjectDTO } from './dto/select-object.dto'; 15 | import { CreateObjectDTO } from './dto/create-object.dto'; 16 | 17 | @Controller('object-database') 18 | export class ObjectDatabaseController { 19 | constructor(private objectHandlerService: ObjectHandlerService) {} 20 | 21 | @Get('/:workspaceId/object') 22 | async selectAllObjects(@Param(new ValidationPipe()) { workspaceId }: CreateTableRequestDto) { 23 | return await this.objectHandlerService.selectAllObjects(workspaceId); 24 | } 25 | 26 | @Get('/:workspaceId/object/:objectId') 27 | async selectOneObject(@Param(new ValidationPipe()) { workspaceId, objectId }: SelectObjectDTO) { 28 | return await this.objectHandlerService.selectObjectById(workspaceId, objectId); 29 | } 30 | 31 | @Post('/:workspaceId/object') 32 | async createObject( 33 | @Param(new ValidationPipe()) { workspaceId }: CreateTableRequestDto, 34 | @Body(new ValidationPipe()) createObjectDTO: CreateObjectDTO, 35 | ) { 36 | const result = await this.objectHandlerService.createObject(workspaceId, createObjectDTO); 37 | if (!result) throw new InternalServerErrorException('알 수 없는 이유로 데이터 추가에 실패하였습니다.'); 38 | } 39 | 40 | @Patch('/:workspaceId/object/:objectId') 41 | async updateObject( 42 | @Param(new ValidationPipe()) { workspaceId, objectId }: SelectObjectDTO, 43 | @Body() createObjectDTO: CreateObjectDTO, 44 | ) { 45 | const result = await this.objectHandlerService.updateObject(workspaceId, createObjectDTO); 46 | if (!result) throw new InternalServerErrorException('알 수 없는 이유로 데이터 갱신에 실패하였습니다.'); 47 | } 48 | 49 | @Delete('/:workspaceId/object/:objectId') 50 | async deleteObject(@Param(new ValidationPipe()) { workspaceId, objectId }: SelectObjectDTO) { 51 | const result = await this.objectHandlerService.deleteObject(workspaceId, objectId); 52 | if (!result) throw new InternalServerErrorException('알 수 없는 이유로 데이터 삭제에 실패하였습니다.'); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /backend/src/object-database/object-database.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ObjectDatabaseController } from './object-database.controller'; 3 | import { ObjectHandlerService } from './object-handler.service'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { MongooseModule } from '@nestjs/mongoose'; 6 | import { ObjectBucket, ObjectBucketSchema } from './schema/object-bucket.schema'; 7 | import { WorkspaceObject } from './entity/workspace-object.entity'; 8 | import { MongooseObjectHandlerService } from './mongoose-object-handler.service'; 9 | 10 | @Module({ 11 | imports: [ 12 | TypeOrmModule.forFeature([WorkspaceObject]), 13 | MongooseModule.forFeature([{ name: ObjectBucket.name, schema: ObjectBucketSchema }]), 14 | ], 15 | controllers: [ObjectDatabaseController], 16 | providers: [ObjectHandlerService, MongooseObjectHandlerService], 17 | exports: [ObjectHandlerService, MongooseObjectHandlerService, TypeOrmModule, MongooseModule], 18 | }) 19 | export class ObjectDatabaseModule {} 20 | -------------------------------------------------------------------------------- /backend/src/object-database/schema/object-bucket.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, SchemaFactory, Schema } from '@nestjs/mongoose'; 2 | import { isUUID } from 'class-validator'; 3 | import { HydratedDocument } from 'mongoose'; 4 | import { WorkspaceObjectInterface } from '../abstract/workspace-object.interface'; 5 | import { workspaceObjectSchema } from './workspace-object.schema'; 6 | 7 | @Schema({ collection: 'boocrum-objects', timestamps: true, skipVersioning: { objects: true } }) 8 | export class ObjectBucket { 9 | @Prop({ 10 | type: String, 11 | unique: true, 12 | immutable: true, 13 | validate: { 14 | validator: (v: string) => isUUID(v), 15 | message: 'Workspace의 ID는 항상 UUID여야 합니다.', 16 | }, 17 | }) 18 | workspaceId: string; 19 | 20 | @Prop({ 21 | type: [workspaceObjectSchema], 22 | default: [], 23 | }) 24 | objects: WorkspaceObjectInterface[]; 25 | } 26 | 27 | export type ObjectBucketDocument = HydratedDocument; 28 | export const ObjectBucketSchema = SchemaFactory.createForClass(ObjectBucket); 29 | -------------------------------------------------------------------------------- /backend/src/object-database/schema/template-bucket.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, SchemaFactory, Schema } from '@nestjs/mongoose'; 2 | import { isURL } from 'class-validator'; 3 | import { HydratedDocument } from 'mongoose'; 4 | import { WorkspaceObjectInterface } from '../abstract/workspace-object.interface'; 5 | import { workspaceObjectSchema } from './workspace-object.schema'; 6 | 7 | @Schema({ collection: 'boocrum-template', timestamps: true, skipVersioning: { objects: true } }) 8 | export class TemplateBucket { 9 | @Prop({ 10 | type: String, 11 | unique: true, 12 | immutable: true, 13 | }) 14 | templateId: string; 15 | 16 | @Prop({ 17 | type: String, 18 | }) 19 | templateName: string; 20 | 21 | @Prop({ 22 | type: String, 23 | validate: (v: string) => !v || isURL(v), 24 | }) 25 | templateThumbnailUrl: string; 26 | 27 | @Prop({ 28 | type: [workspaceObjectSchema], 29 | default: [], 30 | }) 31 | objects: WorkspaceObjectInterface[]; 32 | } 33 | 34 | export type TemplateBucketDocument = HydratedDocument; 35 | export const TemplateBucketSchema = SchemaFactory.createForClass(TemplateBucket); 36 | -------------------------------------------------------------------------------- /backend/src/object-database/schema/workspace-object.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, SchemaFactory, Schema } from '@nestjs/mongoose'; 2 | import { isUUID } from 'class-validator'; 3 | import { HydratedDocument } from 'mongoose'; 4 | 5 | @Schema({ _id: false }) 6 | export class WorkspaceObject implements AbstractWorkspaceObject { 7 | @Prop({ 8 | type: String, 9 | validate: { 10 | validator: (v: string) => isUUID(v), 11 | message: 'Object의 ID는 UUID여야 합니다.', 12 | }, 13 | required: true, 14 | immutable: true, 15 | }) 16 | objectId: string; 17 | 18 | @Prop({ 19 | type: String, 20 | enum: { 21 | values: ['postit', 'section', 'draw'], 22 | message: '{VALUE}는 존재하지 않는 타입입니다.', 23 | }, 24 | required: true, 25 | immutable: true, 26 | }) 27 | type: ObjectType; 28 | 29 | @Prop({ 30 | type: Number, 31 | required: true, 32 | }) 33 | left: number; 34 | 35 | @Prop({ 36 | type: Number, 37 | required: true, 38 | }) 39 | top: number; 40 | 41 | @Prop({ 42 | type: Number, 43 | required: true, 44 | }) 45 | width: number; 46 | 47 | @Prop({ 48 | type: Number, 49 | required: true, 50 | }) 51 | height: number; 52 | 53 | @Prop({ 54 | type: Number, 55 | required: true, 56 | }) 57 | scaleX: number; 58 | 59 | @Prop({ 60 | type: Number, 61 | required: true, 62 | }) 63 | scaleY: number; 64 | 65 | @Prop({ 66 | type: String, 67 | required: true, 68 | }) 69 | color: string; 70 | 71 | @Prop({ 72 | type: String, 73 | required: true, 74 | immutable: true, 75 | }) 76 | creator: string; 77 | 78 | @Prop({ 79 | type: String, 80 | }) 81 | text: string; 82 | 83 | @Prop({ 84 | type: Number, 85 | }) 86 | fontSize: number; 87 | 88 | @Prop({ 89 | type: String, 90 | }) 91 | path: string; 92 | } 93 | 94 | export const workspaceObjectSchema = SchemaFactory.createForClass(WorkspaceObject); 95 | export type WorkspaceObjectDocument = HydratedDocument; 96 | -------------------------------------------------------------------------------- /backend/src/ormconfig.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path'; 2 | import { DataSource, DataSourceOptions } from 'typeorm'; 3 | import * as dotenv from 'dotenv'; 4 | 5 | dotenv.config(); 6 | export const config: DataSourceOptions = { 7 | type: 'mysql', 8 | host: process.env.MYSQL_HOST, 9 | port: parseInt(process.env.MYSQL_PORT), 10 | username: process.env.MYSQL_USERNAME, 11 | password: process.env.MYSQL_PASSWORD, 12 | database: process.env.MYSQL_DATABASE, 13 | entities: [join(__dirname, '/**/*.entity{.ts,.js}')], 14 | migrations: [ 15 | process.env.NODE_ENV !== 'develop' && process.env.NODE_ENV !== 'production' 16 | ? 'src/migrations/**/*.ts' 17 | : 'dist/migrations/**/*.js', 18 | ], 19 | migrationsTableName: 'migrations', 20 | synchronize: process.env.NODE_ENV === 'develop', 21 | }; 22 | export default new DataSource(config); 23 | -------------------------------------------------------------------------------- /backend/src/socket/adapter/custom-socket.adapter.ts: -------------------------------------------------------------------------------- 1 | import { IoAdapter } from '@nestjs/platform-socket.io'; 2 | import { ServerOptions } from 'socket.io'; 3 | import { createAdapter } from '@socket.io/redis-adapter'; 4 | import { Logger } from '@nestjs/common'; 5 | import Redis from 'ioredis'; 6 | import { NestExpressApplication } from '@nestjs/platform-express'; 7 | 8 | export class RedisIoAdapter extends IoAdapter { 9 | private logger = new Logger('RedisIoAdapter'); 10 | private adapterConstructor: ReturnType; 11 | 12 | constructor(app: NestExpressApplication, private port: number) { 13 | super(app); 14 | } 15 | 16 | async connectToRedis(): Promise { 17 | const pubClient = new Redis(parseInt(process.env.REDIS_PORT), process.env.REDIS_HOST); 18 | const subClient = pubClient.duplicate(); 19 | 20 | pubClient.on('error', (err) => this.logger.error(err)); 21 | subClient.on('error', (err) => this.logger.error(err)); 22 | 23 | this.adapterConstructor = createAdapter(pubClient, subClient); 24 | } 25 | 26 | createIOServer(port: number, options?: ServerOptions): any { 27 | port = this.port; 28 | const server = super.createIOServer(port, options); 29 | server.adapter(this.adapterConstructor); 30 | this.logger.log(`Socket Server Now listen ${port}`); 31 | return server; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /backend/src/socket/dto/change-role.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsNumber, Max, Min } from 'class-validator'; 2 | import { WORKSPACE_ROLE } from 'src/util/constant/role.constant'; 3 | 4 | export class ChangeUserRoleDTO { 5 | @IsString() 6 | userId: string; 7 | 8 | @Min(WORKSPACE_ROLE.VIEWER) 9 | @Max(WORKSPACE_ROLE.EDITOR) 10 | @IsNumber() 11 | role: number; 12 | } 13 | -------------------------------------------------------------------------------- /backend/src/socket/dto/object-map.vo.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsOptional, IsString, IsUUID } from 'class-validator'; 2 | 3 | export class ObjectMapVO { 4 | @IsString() 5 | objectId: string; 6 | 7 | @IsString() 8 | type: ObjectType; 9 | 10 | @IsNumber() 11 | left: number; 12 | 13 | @IsNumber() 14 | top: number; 15 | 16 | @IsNumber() 17 | width: number; 18 | 19 | @IsNumber() 20 | height: number; 21 | 22 | @IsNumber() 23 | scaleX: number; 24 | 25 | @IsNumber() 26 | scaleY: number; 27 | 28 | @IsString() 29 | color: string; 30 | 31 | @IsString() 32 | @IsOptional() 33 | text?: string; 34 | 35 | @IsNumber() 36 | @IsOptional() 37 | fontSize?: number; 38 | 39 | @IsString() 40 | @IsOptional() 41 | path?: string; 42 | 43 | @IsString() 44 | @IsOptional() 45 | creator: string; 46 | } 47 | -------------------------------------------------------------------------------- /backend/src/socket/dto/object-move.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsString } from 'class-validator'; 2 | 3 | export class ObjectMoveDTO { 4 | @IsString() 5 | objectId: string; 6 | 7 | @IsNumber() 8 | left: number; 9 | 10 | @IsNumber() 11 | top: number; 12 | } 13 | -------------------------------------------------------------------------------- /backend/src/socket/dto/object-scale.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsString } from 'class-validator'; 2 | 3 | export class ObjectScaleDTO { 4 | @IsString() 5 | objectId: string; 6 | 7 | // @IsNumber() 8 | // dleft: number; 9 | // 10 | // @IsNumber() 11 | // dtop: number; 12 | 13 | @IsNumber() 14 | left: number; 15 | 16 | @IsNumber() 17 | top: number; 18 | 19 | @IsNumber() 20 | scaleX: number; 21 | 22 | @IsNumber() 23 | scaleY: number; 24 | } 25 | -------------------------------------------------------------------------------- /backend/src/socket/dto/user-map.vo.ts: -------------------------------------------------------------------------------- 1 | import { WORKSPACE_ROLE } from 'src/util/constant/role.constant'; 2 | 3 | export class UserMapVO { 4 | constructor( 5 | userId: string, 6 | nickname: string, 7 | workspaceId: string, 8 | role: WORKSPACE_ROLE | number, 9 | color: string, 10 | isGuest = true, 11 | ) { 12 | this.userId = userId; 13 | this.nickname = nickname; 14 | this.workspaceId = workspaceId; 15 | this.role = role; 16 | this.color = color; 17 | this.isGuest = isGuest; 18 | this.count = 0; 19 | } 20 | 21 | userId: string; // 회원 ID 22 | nickname: string; // 회원 닉네임 23 | workspaceId: string; // 워크스페이스 ID 24 | role: WORKSPACE_ROLE | number; // 권한 25 | color: string; // 커서 색상 26 | isGuest: boolean; // 게스트인지, 유저인지 확인. 27 | count: number; // 이 UserMapVO와 연결된 소켓의 개수 28 | } 29 | -------------------------------------------------------------------------------- /backend/src/socket/dto/user.dao.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsString } from 'class-validator'; 2 | import { WORKSPACE_ROLE } from 'src/util/constant/role.constant'; 3 | 4 | export class UserDAO { 5 | constructor(userId: string, nickname: string, color: string, role: WORKSPACE_ROLE | number) { 6 | this.userId = userId; 7 | this.nickname = nickname; 8 | this.color = color; 9 | this.role = role; 10 | } 11 | 12 | @IsString() 13 | userId: string; 14 | 15 | @IsString() 16 | nickname: string; 17 | 18 | @IsString() 19 | color: string; 20 | 21 | @IsNumber() 22 | role: WORKSPACE_ROLE | number; 23 | } 24 | -------------------------------------------------------------------------------- /backend/src/socket/guard/user-role.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Inject, mixin } from '@nestjs/common'; 2 | import { WsException } from '@nestjs/websockets'; 3 | import { Observable } from 'rxjs'; 4 | import { Socket } from 'socket.io'; 5 | import { WORKSPACE_ROLE } from 'src/util/constant/role.constant'; 6 | import { UserManagementService } from '../user-management.service'; 7 | 8 | export const UserRoleGuard = (minimumRole: WORKSPACE_ROLE) => { 9 | class UserRoleGuardMixin implements CanActivate { 10 | constructor(@Inject(UserManagementService) readonly userManagementService: UserManagementService) {} 11 | canActivate(context: ExecutionContext): boolean | Promise | Observable { 12 | const client = context.switchToWs().getClient(); 13 | return this.validate(client.id); 14 | } 15 | 16 | async validate(clientId: string): Promise { 17 | const userData = await this.userManagementService.findUserDataBySocketId(clientId); 18 | if (userData.role < minimumRole) throw new WsException('유효하지 않은 권한입니다.'); 19 | return userData.role >= minimumRole; 20 | } 21 | } 22 | 23 | const guard = mixin(UserRoleGuardMixin); 24 | return guard; 25 | }; 26 | -------------------------------------------------------------------------------- /backend/src/socket/object-management.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ObjectMapVO } from './dto/object-map.vo'; 3 | import { MongooseObjectHandlerService } from '../object-database/mongoose-object-handler.service'; 4 | import { UpdateObjectDTO } from 'src/object-database/dto/update-object.dto'; 5 | import { CreateObjectDTO } from 'src/object-database/dto/create-object.dto'; 6 | 7 | @Injectable() 8 | export class ObjectManagementService { 9 | constructor(private objectHandlerService: MongooseObjectHandlerService) {} 10 | 11 | /** 12 | * 특정 워크스페이스 내의 모든 Object를 반환한다. 13 | * 14 | * 만약 캐싱되지 않은 경우 DB로부터 정보를 불러와 캐싱한 뒤 반환한다. 15 | * @param workspaceId 검색할 워크스페이스의 ID 16 | * @returns 현재 보관 중인 모든 Object 데이터 배열. 없을 경우 빈 배열을 반환한다. 17 | */ 18 | async findAllObjectsInWorkspace(workspaceId: string): Promise { 19 | return await this.objectHandlerService.selectAllObjects(workspaceId); 20 | } 21 | 22 | /** 23 | * 특정 워크스페이스의 특정 Object를 반환한다. 24 | * 25 | * 만약 캐싱되지 않은 경우 DB로부터 모든 정보를 불러와 캐싱한 뒤 탐색한다. 26 | * @param workspaceId 검색할 워크스페이스의 ID 27 | * @param objectId 검색할 Object의 ID 28 | * @returns 지정한 Object에 대한 데이터. 없을 경우 null. 29 | */ 30 | async findOneObjectInWorkspace(workspaceId: string, objectId: string): Promise { 31 | return this.objectHandlerService.selectObjectById(workspaceId, objectId); 32 | } 33 | 34 | /** 35 | * 특정 워크스페이스에 새로운 Object를 추가한다. 이미 존재할 경우 오류를 반환한다. 36 | * 37 | * 만약 캐싱되지 않은 경우 DB로부터 모든 정보를 불러온 뒤 삽입한다. 38 | * @param workspaceId Object를 삽입할 워크스페이스의 ID 39 | * @param objectDto 삽입할 Object에 관한 데이터 40 | */ 41 | async insertObjectIntoWorkspace(workspaceId: string, objectDto: CreateObjectDTO): Promise { 42 | await this.objectHandlerService.createObject(workspaceId, objectDto); 43 | } 44 | 45 | /** 46 | * 특정 워크스페이스에 존재하는 특정 Object를 갱신한다. 존재하지 않을 경우 오류를 반환한다. 47 | * 48 | * 만약 캐싱되지 않은 경우 DB로부터 모든 정보를 불러온 뒤 갱신한다. 49 | * @param workspaceId Object를 수정할 워크스페이스의 ID 50 | * @param objectDto 갱신할 Object에 관한 데이터 51 | */ 52 | async updateObjectInWorkspace(workspaceId: string, objectDto: UpdateObjectDTO): Promise { 53 | await this.objectHandlerService.updateObject(workspaceId, objectDto); 54 | } 55 | 56 | /** 57 | * 특정 워크스페이스에 존재하는 특정 Object를 제거한다. 존재하지 않을 경우 무시한다. 58 | * @param workspaceId Object를 제거할 워크스페이스의 ID 59 | * @param objectId 제거할 Object의 ID 60 | */ 61 | async deleteObjectInWorkspace(workspaceId: string, objectId: string): Promise { 62 | return await this.objectHandlerService.deleteObject(workspaceId, objectId); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /backend/src/socket/pipe/object-transform.pipe.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentMetadata, Injectable, PipeTransform } from '@nestjs/common'; 2 | import { WsException } from '@nestjs/websockets'; 3 | import { CreateObjectDTO } from 'src/object-database/dto/create-object.dto'; 4 | import { UpdateObjectDTO } from 'src/object-database/dto/update-object.dto'; 5 | 6 | const isNullOrUndefined = (v: any): boolean => v === null || v === undefined; 7 | 8 | /** 9 | * Validation Pipe를 거친 Object Dto에 대해 추가적인 수정을 가합니다. 10 | */ 11 | @Injectable() 12 | export class ObjectTransformPipe implements PipeTransform { 13 | transform(value: any, metadata: ArgumentMetadata) { 14 | switch (metadata.metatype) { 15 | case CreateObjectDTO: 16 | case UpdateObjectDTO: 17 | break; 18 | default: 19 | throw new Error('잘못된 사용법입니다. Create/UpdateObjectDTO에 대해서만 사용해주시기 바랍니다.'); 20 | } 21 | return this.tranformEntryPoint(value); 22 | } 23 | 24 | private tranformEntryPoint(obj: AbstractPartialWorkspaceObject) { 25 | delete obj.creator; 26 | switch (obj.type) { 27 | case 'postit': 28 | return this.postitTransformer(obj); 29 | case 'section': 30 | return this.sectionTransformer(obj); 31 | case 'draw': 32 | return this.drawTransformer(obj); 33 | default: 34 | throw new WsException('Validation Error: 잘못된 Type 전달입니다.'); 35 | } 36 | } 37 | 38 | private postitTransformer(obj: AbstractPartialWorkspaceObject) { 39 | // 없애야 할 데이터를 제거한다. 40 | delete obj.path; 41 | if (!isNullOrUndefined(obj.fontSize) && obj.fontSize <= 0) 42 | throw new WsException('Font 크기는 0 이하가 될 수 없습니다.'); 43 | 44 | return obj; 45 | } 46 | 47 | private sectionTransformer(obj: AbstractPartialWorkspaceObject) { 48 | // 없애야 할 데이터를 제거한다. 49 | delete obj.path; 50 | 51 | // 데이터를 제어합니다. 52 | if (!isNullOrUndefined(obj.fontSize) && obj.fontSize <= 0) 53 | throw new WsException('Font 크기는 0 이하가 될 수 없습니다.'); 54 | if (!isNullOrUndefined(obj.text) && obj.text.length > 50) obj.text = obj.text.slice(0, 50); // 오류 대신 50자로 잘라버린다. 55 | return obj; 56 | } 57 | 58 | private drawTransformer(obj: AbstractPartialWorkspaceObject) { 59 | // 없애야 할 데이터를 제거한다. 60 | delete obj.text; 61 | delete obj.fontSize; 62 | 63 | return obj; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /backend/src/socket/pipe/object-updating.pipe.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentMetadata, Injectable, PipeTransform } from '@nestjs/common'; 2 | import { WsException } from '@nestjs/websockets'; 3 | import { CreateObjectDTO } from 'src/object-database/dto/create-object.dto'; 4 | import { UpdateObjectDTO } from 'src/object-database/dto/update-object.dto'; 5 | 6 | const isNullOrUndefined = (v: any): boolean => v === null || v === undefined; 7 | 8 | /** 9 | * Validation Pipe를 거친 Object Dto에 대해 추가적인 수정을 가합니다. 10 | */ 11 | @Injectable() 12 | export class ObjectUpdatingPipe { 13 | transform(value: any, metadata: ArgumentMetadata) { 14 | switch (metadata.metatype) { 15 | case UpdateObjectDTO: 16 | break; 17 | default: 18 | throw new Error('잘못된 사용법입니다. UpdateObjectDTO에 대해서만 사용해주시기 바랍니다.'); 19 | } 20 | return value; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /backend/src/socket/socket.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { HttpModule } from '@nestjs/axios'; 3 | import { SocketGateway } from './socket.gateway'; 4 | import { ObjectDatabaseModule } from 'src/object-database/object-database.module'; 5 | import { ObjectHandlerService } from 'src/object-database/object-handler.service'; 6 | import { DbAccessService } from './db-access.service'; 7 | import { UserManagementService } from './user-management.service'; 8 | import { ObjectManagementService } from './object-management.service'; 9 | import { RedisModule } from '@liaoliaots/nestjs-redis'; 10 | import { ConfigModule } from '@nestjs/config'; 11 | 12 | @Module({ 13 | imports: [ 14 | ConfigModule.forRoot(), 15 | HttpModule, 16 | ObjectDatabaseModule, 17 | RedisModule.forRoot({ 18 | config: [ 19 | { 20 | namespace: 'SocketUser', 21 | host: process.env.REDIS_HOST, 22 | port: parseInt(process.env.REDIS_PORT), 23 | db: parseInt(process.env.REDIS_SOCKET_USER_DB), 24 | }, 25 | { 26 | namespace: 'WorkspaceUser', 27 | host: process.env.REDIS_HOST, 28 | port: parseInt(process.env.REDIS_PORT), 29 | db: parseInt(process.env.REDIS_WORKSPACE_USER_DB), 30 | }, 31 | ], 32 | }), 33 | ], 34 | providers: [SocketGateway, ObjectHandlerService, DbAccessService, UserManagementService, ObjectManagementService], 35 | }) 36 | export class SocketModule {} 37 | -------------------------------------------------------------------------------- /backend/src/team/dto/team.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, isString, MaxLength } from 'class-validator'; 2 | 3 | // 팀 생성 시 팀명과 userId를 필수로 받아와야 하므로 DTO 생성하였음 4 | export class TeamDTO { 5 | @IsNotEmpty() 6 | @MaxLength(50) 7 | userId: string; 8 | 9 | @IsNotEmpty() 10 | @MaxLength(50) 11 | name: string; 12 | 13 | @MaxLength(1024) 14 | description: string; 15 | } 16 | -------------------------------------------------------------------------------- /backend/src/team/entity/team-member.entity.ts: -------------------------------------------------------------------------------- 1 | import { Team } from '../../team/entity/team.entity'; 2 | import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, JoinColumn } from 'typeorm'; 3 | import { User } from '../../user/entity/user.entity'; 4 | import { TEAM_ROLE } from '../../util/constant/role.constant'; 5 | 6 | @Entity({ name: 'team_member' }) 7 | export class TeamMember { 8 | constructor(user: User, team: Team, role: TEAM_ROLE) { 9 | this.user = user; 10 | this.team = team; 11 | this.role = role; 12 | } 13 | 14 | @PrimaryGeneratedColumn('increment') 15 | id: number; 16 | 17 | @ManyToOne(() => User, (user) => user.teamMember, { nullable: false }) 18 | @JoinColumn({ name: 'user_id' }) 19 | user: User; 20 | 21 | @ManyToOne(() => Team, (team) => team.teamMember, { nullable: false }) 22 | @JoinColumn({ name: 'team_id' }) 23 | team: Team; 24 | 25 | @Column({ type: 'tinyint', default: TEAM_ROLE.MEMBER }) 26 | role: number; 27 | } 28 | -------------------------------------------------------------------------------- /backend/src/team/entity/team.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Column, PrimaryGeneratedColumn, OneToMany } from 'typeorm'; 2 | import { Workspace } from '../../workspace/entity/workspace.entity'; 3 | import { TeamMember } from './team-member.entity'; 4 | import { IsTeam } from '../enum/is-team.enum'; 5 | 6 | @Entity() 7 | export class Team { 8 | constructor(name: string, isTeam: IsTeam, description?: string) { 9 | this.name = name; 10 | this.isTeam = isTeam; 11 | this.description = description; 12 | } 13 | 14 | @PrimaryGeneratedColumn('increment', { name: 'team_id', type: 'int' }) 15 | teamId: number; 16 | 17 | @Column({ type: 'varchar', length: 50, nullable: false }) 18 | name: string; 19 | 20 | @Column({ type: 'varchar', length: 1024, nullable: true }) 21 | description: string; 22 | 23 | @Column({ 24 | name: 'is_team', 25 | type: 'tinyint', 26 | default: IsTeam.TEAM, 27 | nullable: false, 28 | }) 29 | isTeam: number; 30 | 31 | @Column({ 32 | name: 'register_date', 33 | type: 'timestamp', 34 | default: () => 'CURRENT_TIMESTAMP()', 35 | nullable: false, 36 | }) 37 | registerDate: Date; 38 | 39 | @OneToMany(() => Workspace, (workspace) => workspace.team) 40 | workspace: Workspace[]; 41 | 42 | @OneToMany(() => TeamMember, (teamMember) => teamMember.team) 43 | teamMember: TeamMember[]; 44 | } 45 | -------------------------------------------------------------------------------- /backend/src/team/enum/is-team.enum.ts: -------------------------------------------------------------------------------- 1 | export enum IsTeam { 2 | USER, 3 | TEAM, 4 | } 5 | -------------------------------------------------------------------------------- /backend/src/team/team.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TeamService } from './team.service'; 3 | import { TeamController } from './team.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { Team } from './entity/team.entity'; 6 | import { TeamMember } from './entity/team-member.entity'; 7 | import { UserModule } from '../user/user.module'; 8 | 9 | @Module({ 10 | imports: [TypeOrmModule.forFeature([Team, TeamMember]), UserModule], 11 | providers: [TeamService], 12 | controllers: [TeamController], 13 | }) 14 | export class TeamModule {} 15 | -------------------------------------------------------------------------------- /backend/src/types/auth.d.ts: -------------------------------------------------------------------------------- 1 | declare type UserSessionData = { 2 | userId: string; 3 | nickname: string; 4 | registerDate: string; 5 | userTeamId: number; 6 | }; 7 | -------------------------------------------------------------------------------- /backend/src/types/object.d.ts: -------------------------------------------------------------------------------- 1 | declare type ObjectType = 'postit' | 'section' | 'draw'; 2 | 3 | declare class AbstractWorkspaceObject { 4 | objectId: string; 5 | type: ObjectType; 6 | left: number; 7 | top: number; 8 | width: number; 9 | height: number; 10 | scaleX: number; 11 | scaleY: number; 12 | color: string; 13 | text: string; 14 | fontSize: number; 15 | path: string; 16 | creator: string; 17 | } 18 | 19 | declare class AbstractPartialWorkspaceObject { 20 | objectId: string; 21 | type: ObjectType; 22 | left?: number; 23 | top?: number; 24 | width?: number; 25 | height?: number; 26 | scaleX?: number; 27 | scaleY?: number; 28 | color?: string; 29 | text?: string; 30 | fontSize?: number; 31 | path?: string; 32 | creator?: string; 33 | } 34 | -------------------------------------------------------------------------------- /backend/src/types/session.d.ts: -------------------------------------------------------------------------------- 1 | import { Session } from 'express-session'; 2 | 3 | declare module 'http' { 4 | interface IncomingMessage { 5 | session: Session & { 6 | user: UserSessionData; 7 | }; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /backend/src/types/socket.d.ts: -------------------------------------------------------------------------------- 1 | import { ObjectMapVO } from 'src/socket/dto/object-map.vo'; 2 | 3 | declare type WorkspaceObjectMapper = { 4 | timeout: NodeJS.Timeout; 5 | objects: Map; 6 | }; 7 | -------------------------------------------------------------------------------- /backend/src/types/workspace.d.ts: -------------------------------------------------------------------------------- 1 | declare type WORKSPACE_FILTERS = 'last-created' | 'last-updated' | 'alphabetically'; 2 | -------------------------------------------------------------------------------- /backend/src/user/dto/partial-search.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsString, Min } from 'class-validator'; 2 | 3 | export class PartialSearchRequestDto { 4 | @IsString() 5 | filter: WORKSPACE_FILTERS; 6 | 7 | @Min(1, { message: 'page는 1 이상의 숫자로 제공해주십시오.' }) 8 | @IsNumber() 9 | page: number; 10 | } 11 | -------------------------------------------------------------------------------- /backend/src/user/dto/user.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsString, MaxLength } from 'class-validator'; 2 | 3 | export class UserDto { 4 | @IsString() 5 | userId?: string; 6 | 7 | @MaxLength(50) 8 | @IsString() 9 | nickname?: string; 10 | 11 | @IsNumber({ 12 | allowNaN: false, 13 | allowInfinity: false, 14 | }) 15 | registerDate?: Date; 16 | } 17 | -------------------------------------------------------------------------------- /backend/src/user/entity/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, PrimaryColumn, Column, OneToMany } from 'typeorm'; 2 | import { TeamMember } from '../../team/entity/team-member.entity'; 3 | import { WorkspaceMember } from '../../workspace/entity/workspace-member.entity'; 4 | 5 | @Entity() 6 | export class User { 7 | @PrimaryColumn({ name: 'user_id', type: 'varchar', length: 50 }) 8 | userId: string; 9 | 10 | @Column({ name: 'nickname', type: 'varchar', length: 50, nullable: false }) 11 | nickname: string; 12 | 13 | @Column({ 14 | name: 'register_date', 15 | type: 'timestamp', 16 | default: () => 'CURRENT_TIMESTAMP()', 17 | nullable: false, 18 | }) 19 | registerDate: Date; 20 | 21 | @OneToMany(() => TeamMember, (tm) => tm.user) 22 | teamMember: TeamMember[]; 23 | 24 | @OneToMany(() => WorkspaceMember, (wm) => wm.user) 25 | workspaceMember: WorkspaceMember[]; 26 | } 27 | -------------------------------------------------------------------------------- /backend/src/user/pipe/part-search-transform.pipe.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentMetadata, Injectable, PipeTransform } from '@nestjs/common'; 2 | import { PartialSearchRequestDto } from '../dto/partial-search.dto'; 3 | 4 | @Injectable() 5 | export class PartSearchTransformPipe implements PipeTransform { 6 | transform(value: any, metadata: ArgumentMetadata) { 7 | if (metadata.metatype !== PartialSearchRequestDto) return value; 8 | 9 | if ((value as Record).hasOwnProperty('page')) value.page = +value.page; 10 | return value; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /backend/src/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { UserService } from './user.service'; 3 | import { UserController } from './user.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { User } from './entity/user.entity'; 6 | 7 | @Module({ 8 | imports: [TypeOrmModule.forFeature([User])], 9 | providers: [UserService], 10 | controllers: [UserController], 11 | exports: [UserService], 12 | }) 13 | export class UserModule {} 14 | -------------------------------------------------------------------------------- /backend/src/user/user.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { UserService } from './user.service'; 3 | 4 | describe('UserService', () => { 5 | // let service: UserService; 6 | 7 | // beforeEach(async () => { 8 | // const module: TestingModule = await Test.createTestingModule({ 9 | // providers: [UserService], 10 | // }).compile(); 11 | 12 | // service = module.get(UserService); 13 | // }); 14 | 15 | it('should be defined', () => { 16 | // expect(service).toBeDefined(); 17 | expect(1).toBe(1); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /backend/src/util/constant/role.constant.ts: -------------------------------------------------------------------------------- 1 | // Team 2 | export const TEAM_ROLE = { 3 | MEMBER: 0, 4 | MANAGER: 1, 5 | ADMIN: 2, 6 | } as const; 7 | 8 | export type TEAM_ROLE = typeof TEAM_ROLE[keyof typeof TEAM_ROLE]; 9 | 10 | // Workspace 11 | export const WORKSPACE_ROLE = { 12 | NOT_FOUND: -1, 13 | VIEWER: 0, 14 | EDITOR: 1, 15 | OWNER: 2, 16 | } as const; 17 | 18 | export type WORKSPACE_ROLE = typeof WORKSPACE_ROLE[keyof typeof WORKSPACE_ROLE]; 19 | -------------------------------------------------------------------------------- /backend/src/workspace/dto/workspace.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsDate, IsNumber, IsString, IsUUID } from 'class-validator'; 2 | 3 | export class WorkspaceDto { 4 | @IsUUID() 5 | workspaceId?: string; 6 | 7 | @IsNumber() 8 | teamId?: number; 9 | 10 | @IsString() 11 | description?: string; 12 | 13 | @IsString() 14 | name?: string; 15 | 16 | @IsDate() 17 | registerDate: Date; 18 | 19 | @IsDate() 20 | updateDate: number; 21 | 22 | workspaceMember?: string[]; 23 | } 24 | -------------------------------------------------------------------------------- /backend/src/workspace/dto/workspaceCreateRequest.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsString, IsOptional, IsEmpty } from 'class-validator'; 2 | 3 | export class WorkspaceCreateRequestDto { 4 | @IsNumber() 5 | @IsOptional() 6 | teamId: number; 7 | 8 | @IsEmpty() // 현재는 외부 입력은 없으며, Session 정보를 그대로 활용한다. 9 | ownerId: string; 10 | 11 | @IsString() 12 | @IsOptional() 13 | name: string; 14 | 15 | @IsString() 16 | @IsOptional() 17 | description: string; 18 | } 19 | -------------------------------------------------------------------------------- /backend/src/workspace/dto/workspaceId.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsUUID } from 'class-validator'; 2 | 3 | export class WorkspaceIdDto { 4 | @IsUUID() 5 | workspaceId: string; 6 | } 7 | -------------------------------------------------------------------------------- /backend/src/workspace/dto/workspaceMetadata.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsOptional, IsString } from 'class-validator'; 2 | 3 | export class WorkspaceMetadataDto { 4 | @IsString() 5 | @IsOptional() 6 | description: string; 7 | 8 | @IsString() 9 | @IsOptional() 10 | name: string; 11 | } 12 | -------------------------------------------------------------------------------- /backend/src/workspace/entity/workspace-member.entity.ts: -------------------------------------------------------------------------------- 1 | import { WORKSPACE_ROLE } from '../../util/constant/role.constant'; 2 | import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, JoinColumn } from 'typeorm'; 3 | import { User } from '../../user/entity/user.entity'; 4 | import { Workspace } from './workspace.entity'; 5 | 6 | @Entity({ name: 'workspace_member' }) 7 | export class WorkspaceMember { 8 | @PrimaryGeneratedColumn('increment') 9 | id: number; 10 | 11 | @ManyToOne(() => User, (user) => user.teamMember) 12 | @JoinColumn({ name: 'user_id' }) 13 | user: User; 14 | 15 | @ManyToOne(() => Workspace, (workspace) => workspace.workspaceMember) 16 | @JoinColumn({ name: 'workspace_id' }) 17 | workspace: Workspace; 18 | 19 | @Column({ type: 'tinyint', default: WORKSPACE_ROLE.VIEWER }) 20 | role: number; 21 | 22 | @Column({ 23 | name: 'update_date', 24 | type: 'timestamp', 25 | default: () => 'CURRENT_TIMESTAMP()', 26 | nullable: false, 27 | }) 28 | updateDate: Date; 29 | } 30 | -------------------------------------------------------------------------------- /backend/src/workspace/entity/workspace.entity.ts: -------------------------------------------------------------------------------- 1 | import { WorkspaceObject } from '../../object-database/entity/workspace-object.entity'; 2 | import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, JoinColumn, OneToMany } from 'typeorm'; 3 | import { Team } from '../../team/entity/team.entity'; 4 | import { WorkspaceMember } from './workspace-member.entity'; 5 | 6 | @Entity() 7 | export class Workspace { 8 | @PrimaryGeneratedColumn('uuid', { name: 'workspace_id' }) 9 | workspaceId: string; 10 | 11 | @ManyToOne(() => Team, (team) => team.workspace, { nullable: false }) 12 | @JoinColumn({ name: 'team_id' }) 13 | team: Team; 14 | 15 | @Column({ type: 'varchar', length: 1024, nullable: true }) 16 | description: string; 17 | 18 | @Column({ type: 'varchar', length: 50, nullable: false }) 19 | name: string; 20 | 21 | @Column({ 22 | name: 'register_date', 23 | type: 'timestamp', 24 | default: () => 'CURRENT_TIMESTAMP()', 25 | nullable: false, 26 | }) 27 | registerDate: Date; 28 | 29 | @Column({ 30 | name: 'update_date', 31 | type: 'timestamp', 32 | default: () => 'CURRENT_TIMESTAMP()', 33 | nullable: false, 34 | }) 35 | updateDate: Date; 36 | 37 | @Column({ 38 | name: 'thumbnail', 39 | type: 'varchar', 40 | length: 2083, // 참고: http://daplus.net/sql-url%EC%97%90-%EA%B0%80%EC%9E%A5-%EC%A0%81%ED%95%A9%ED%95%9C-%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%B2%A0%EC%9D%B4%EC%8A%A4-%ED%95%84%EB%93%9C-%EC%9C%A0%ED%98%95/ 41 | nullable: true, 42 | }) 43 | thumbnailUrl: string; 44 | 45 | @OneToMany(() => WorkspaceMember, (workspaceMember) => workspaceMember.workspace) 46 | workspaceMember: WorkspaceMember[]; 47 | 48 | @OneToMany(() => WorkspaceObject, (wo) => wo.workspace) 49 | workspaceObjects: WorkspaceObject[]; 50 | } 51 | -------------------------------------------------------------------------------- /backend/src/workspace/file-interceptor/thumbnail.interceptor.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import * as AWS from 'aws-sdk'; 3 | import { FileInterceptor } from '@nestjs/platform-express'; 4 | import * as MulterS3 from 'multer-s3'; 5 | 6 | const ObjectStorageConfig = { 7 | access_key: process.env.OBJECT_STORAGE_KEY_ID, 8 | secret_key: process.env.OBJECT_STORAGE_SECRET, 9 | region: process.env.OBJECT_STORAGE_REGION, 10 | endpoint: process.env.OBJECT_STORAGE_ENDPOINT, 11 | bucket: process.env.OBJECT_STORAGE_BUCKET, 12 | }; 13 | 14 | const s3 = new AWS.S3({ 15 | endpoint: ObjectStorageConfig.endpoint, 16 | region: ObjectStorageConfig.region, 17 | credentials: { 18 | accessKeyId: ObjectStorageConfig.access_key, 19 | secretAccessKey: ObjectStorageConfig.secret_key, 20 | }, 21 | }); 22 | 23 | export const ThumbnailInterceptor = FileInterceptor('file', { 24 | storage: new MulterS3({ 25 | s3: s3, 26 | bucket: ObjectStorageConfig.bucket, 27 | acl: 'public-read', 28 | key: function (request, file, cb) { 29 | cb(null, `thumbnail/${Date.now().toString()}-${file.originalname}`); 30 | }, 31 | }), 32 | limits: {}, 33 | }); 34 | -------------------------------------------------------------------------------- /backend/src/workspace/workspace.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { WorkspaceService } from './workspace.service'; 3 | import { WorkspaceController } from './workspace.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { Workspace } from './entity/workspace.entity'; 6 | import { WorkspaceMember } from './entity/workspace-member.entity'; 7 | import { ObjectDatabaseModule } from '../object-database/object-database.module'; 8 | import { MongooseModule } from '@nestjs/mongoose'; 9 | import { ObjectBucket, ObjectBucketSchema } from 'src/object-database/schema/object-bucket.schema'; 10 | import { TemplateBucket, TemplateBucketSchema } from 'src/object-database/schema/template-bucket.schema'; 11 | 12 | @Module({ 13 | imports: [ 14 | TypeOrmModule.forFeature([Workspace, WorkspaceMember]), 15 | MongooseModule.forFeature([ 16 | { name: ObjectBucket.name, schema: ObjectBucketSchema }, 17 | { name: TemplateBucket.name, schema: TemplateBucketSchema }, 18 | ]), 19 | ObjectDatabaseModule, 20 | ], 21 | providers: [WorkspaceService], 22 | controllers: [WorkspaceController], 23 | }) 24 | export class WorkspaceModule {} 25 | -------------------------------------------------------------------------------- /backend/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /backend/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/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /backend/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 | -------------------------------------------------------------------------------- /frontend/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": ["@typescript-eslint", "prettier"], 4 | "extends": ["plugin:@typescript-eslint/recommended", "plugin:react/recommended", "plugin:prettier/recommended"], 5 | "rules": {"prettier/prettier": ["error", { "endOfLine": "auto" }],"react/react-in-jsx-scope": "off"}, 6 | "ignorePatterns": ["craco.config.js"], 7 | } 8 | -------------------------------------------------------------------------------- /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 | 14 | # misc 15 | .DS_Store 16 | .env 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/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "es5", 4 | "singleQuote": true, 5 | "printWidth": 120, 6 | "parser": "typescript", 7 | "useTabs": true, 8 | "tabWidth": 2 9 | } -------------------------------------------------------------------------------- /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/craco.config.js: -------------------------------------------------------------------------------- 1 | const cracoAlias = require('craco-alias'); 2 | 3 | module.exports = { 4 | plugins: [ 5 | { 6 | plugin: cracoAlias, 7 | options: { 8 | source: 'tsconfig', 9 | baseUrl: './src', 10 | tsConfigPath: 'tsconfig.json', 11 | }, 12 | }, 13 | ], 14 | }; 15 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@craco/craco": "^7.0.0", 7 | "@testing-library/jest-dom": "^5.16.5", 8 | "@testing-library/react": "^13.4.0", 9 | "@testing-library/user-event": "^13.5.0", 10 | "@types/fabric": "https://registry.npmjs.org/@seonghunyang/boocrum-types/-/boocrum-types-0.0.8.tgz", 11 | "@types/jest": "^27.5.2", 12 | "@types/lz-string": "^1.3.34", 13 | "@types/node": "^16.18.3", 14 | "@types/offscreencanvas": "^2019.7.0", 15 | "@types/react": "^18.0.25", 16 | "@types/react-dom": "^18.0.8", 17 | "axios": "^1.1.3", 18 | "fabric": "^5.2.4", 19 | "react": "^18.2.0", 20 | "react-dom": "^18.2.0", 21 | "react-router-dom": "^6.4.3", 22 | "react-scripts": "5.0.1", 23 | "recoil": "^0.7.6", 24 | "socket.io-client": "^4.5.4", 25 | "styled-components": "^5.3.6", 26 | "typescript": "^4.8.4", 27 | "uuid": "^9.0.0", 28 | "web-vitals": "^2.1.4" 29 | }, 30 | "scripts": { 31 | "start": "craco start", 32 | "build": "craco build", 33 | "test": "craco test", 34 | "eject": "craco eject", 35 | "lint": "eslint './src/**/*.{ts,tsx,js,jsx}'", 36 | "lint:fix": "eslint --fix './src/**/*.{ts,tsx,js,jsx}'", 37 | "prettier": "prettier --write --config ./.prettierrc './src/**/*.{ts,tsx}'" 38 | }, 39 | "eslintConfig": { 40 | "extends": [ 41 | "react-app", 42 | "react-app/jest" 43 | ] 44 | }, 45 | "browserslist": { 46 | "production": [ 47 | ">0.2%", 48 | "not dead", 49 | "not op_mini all" 50 | ], 51 | "development": [ 52 | "last 1 chrome version", 53 | "last 1 firefox version", 54 | "last 1 safari version" 55 | ] 56 | }, 57 | "devDependencies": { 58 | "@types/styled-components": "^5.1.26", 59 | "@types/uuid": "^8.3.4", 60 | "@typescript-eslint/eslint-plugin": "^5.42.1", 61 | "@typescript-eslint/parser": "^5.42.1", 62 | "craco-alias": "^3.0.1", 63 | "eslint": "^8.27.0", 64 | "eslint-config-prettier": "^8.5.0", 65 | "eslint-plugin-prettier": "^4.2.1", 66 | "eslint-plugin-react": "^7.31.10", 67 | "lz-string": "^1.4.4", 68 | "prettier": "^2.7.1" 69 | }, 70 | "proxy": "https://boocrum.run" 71 | } 72 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web22-BooCrum/99ea3a697c4db9a38a0e049a0b59df9c395c1b79/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | BooCrum 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /frontend/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web22-BooCrum/99ea3a697c4db9a38a0e049a0b59df9c395c1b79/frontend/public/logo.png -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "logo.png", 7 | "sizes": "50x50", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Routes, Route } from 'react-router-dom'; 2 | import ProtectedRoute from '@components/protected-route'; 3 | import { lazy, Suspense } from 'react'; 4 | import Loading from '@components/loading'; 5 | 6 | const Main = lazy(() => import('@pages/main')); 7 | const Login = lazy(() => import('@pages/login')); 8 | const Workspace = lazy(() => import('@pages/workspace')); 9 | const Error = lazy(() => import('@pages/error')); 10 | 11 | function App() { 12 | return ( 13 | }> 14 | 15 | 19 |
20 | 21 | } 22 | /> 23 | } /> 24 | } /> 25 | } /> 26 | 27 | 28 | ); 29 | } 30 | 31 | export default App; 32 | -------------------------------------------------------------------------------- /frontend/src/api/user.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosResponse } from 'axios'; 2 | import { TeamData, ProfileData, WorkspaceData, UserData, PatchProfileBody } from './user.types'; 3 | 4 | const instance = axios.create({ 5 | baseURL: '/api/user', 6 | timeout: 15000, 7 | withCredentials: true, 8 | }); 9 | 10 | const responseBody = (response: AxiosResponse) => response.data; 11 | const responseStatus = (response: AxiosResponse) => response.status; 12 | 13 | const userRequests = { 14 | get: (url: string) => instance.get(url).then(responseBody), 15 | patch: (url: string, body: B) => instance.patch(url, body).then(responseBody), 16 | delete: (url: string) => instance.delete(url).then(responseStatus), 17 | }; 18 | 19 | export const User = { 20 | getProfile: (): Promise => userRequests.get('/info/profile'), 21 | getTeam: (): Promise => userRequests.get('/info/team'), 22 | getWorkspace: (): Promise => userRequests.get('/info/workspace'), 23 | getFilteredWorkspace: (filter: string, page: number): Promise => 24 | userRequests.get(`/info/workspace/${filter}/${page}`), 25 | getAll: (): Promise => userRequests.get('/info'), 26 | patchProfile: (body: PatchProfileBody): Promise => 27 | userRequests.patch('/info', body), 28 | deleteUser: (): Promise => userRequests.delete(''), 29 | getAllById: (userId: string): Promise => userRequests.get(`/${userId}/info`), 30 | getProfileById: (userId: string): Promise => userRequests.get(`/${userId}/info/profile`), 31 | getTeamById: (userId: string): Promise => userRequests.get(`/${userId}/info/team`), 32 | getWorkspaceById: (userId: string): Promise => 33 | userRequests.get(`/${userId}/info/workspace`), 34 | }; 35 | -------------------------------------------------------------------------------- /frontend/src/api/user.types.ts: -------------------------------------------------------------------------------- 1 | export interface ProfileData { 2 | userId: string; 3 | nickname: string; 4 | registerDate: string; 5 | } 6 | 7 | type Role = 0 | 1 | 2; 8 | 9 | interface TeamInfo { 10 | name: 'string'; 11 | isTeam: number; 12 | description: string; 13 | teamId: number; 14 | registerDate: string; 15 | } 16 | 17 | export interface TeamData { 18 | team: TeamInfo; 19 | role: Role; 20 | } 21 | 22 | interface WorkspaceInfo { 23 | workspaceId: 'string'; 24 | description: null; 25 | name: 'string'; 26 | registerDate: 'string'; 27 | updateDate: 'string'; 28 | thumbnailUrl: string; 29 | } 30 | 31 | export interface WorkspaceData { 32 | workspace: WorkspaceInfo; 33 | role: Role; 34 | } 35 | 36 | export interface UserData extends ProfileData { 37 | teamMember: TeamData[]; 38 | workspaceMember: WorkspaceData[]; 39 | } 40 | 41 | export interface PatchProfileBody { 42 | nickname: string; 43 | } 44 | -------------------------------------------------------------------------------- /frontend/src/api/workspace.ts: -------------------------------------------------------------------------------- 1 | import { TemplateType } from '@pages/main/workspace-template-list/index.type'; 2 | import axios, { AxiosResponse, AxiosRequestConfig } from 'axios'; 3 | import { 4 | ParticipantInfo, 5 | PatchWorkspaceBody, 6 | PostThumbnailBody, 7 | PostWorkspaceBody, 8 | WorkspaceData, 9 | WorkspaceMetaData, 10 | } from './workspace.type'; 11 | 12 | const instance = axios.create({ 13 | baseURL: '/api/workspace', 14 | timeout: 15000, 15 | withCredentials: true, 16 | }); 17 | 18 | const responseBody = (response: AxiosResponse) => response.data; 19 | const responseStatus = (response: AxiosResponse) => response.status; 20 | 21 | const workspaceRequests = { 22 | get: (url: string, option?: AxiosRequestConfig) => instance.get(url, option).then(responseBody), 23 | post: (url: string, body: B) => instance.post(url, body).then(responseBody), 24 | postFormData: (url: string, body: B) => 25 | instance 26 | .post(url, body, { 27 | headers: { 28 | 'Content-Type': 'multipart/form-data', 29 | }, 30 | }) 31 | .then(responseBody), 32 | patch: (url: string, body: B) => instance.patch(url, body).then(responseBody), 33 | delete: (url: string) => instance.delete(url).then(responseStatus), 34 | }; 35 | 36 | export const Workspace = { 37 | postWorkspace: (templateId: string, body: PostWorkspaceBody): Promise => 38 | workspaceRequests.post(`${templateId ? `?templateId=${templateId}` : ''}`, body), 39 | patchWorkspace: (workspaceId: string, body: PatchWorkspaceBody): Promise => 40 | workspaceRequests.patch(`/${workspaceId}/info/metadata`, body), 41 | getWorkspaceMetadata: (workspaceId: string): Promise => 42 | workspaceRequests.get(`/${workspaceId}/info/metadata`), 43 | deleteWorkspace: (workspaceId: string): Promise => workspaceRequests.delete(`/${workspaceId}`), 44 | getWorkspaceParticipant: (workspaceId: string): Promise => 45 | workspaceRequests.get(`/${workspaceId}/info/participant`), 46 | getTemplates: (): Promise => workspaceRequests.get(`/template`), 47 | postThumbnail: (workspaceId: string, body: PostThumbnailBody): Promise => 48 | workspaceRequests.postFormData(`/${workspaceId}/thumbnail`, body), 49 | }; 50 | -------------------------------------------------------------------------------- /frontend/src/api/workspace.type.ts: -------------------------------------------------------------------------------- 1 | interface TeamInfo { 2 | name: 'string'; 3 | isTeam: number; 4 | description: string; 5 | teamId: number; 6 | registerDate: string; 7 | } 8 | 9 | export interface WorkspaceData { 10 | team: TeamInfo; 11 | name: string; 12 | description: string; 13 | workspaceId: string; 14 | registerDate: string; 15 | updateDate: string; 16 | } 17 | 18 | export interface PostWorkspaceBody { 19 | teamId?: number; 20 | name?: string; 21 | description?: string; 22 | } 23 | 24 | export interface PatchWorkspaceBody { 25 | name?: string; 26 | } 27 | 28 | export interface WorkspaceMetaData { 29 | workspaceId: string; 30 | description?: string; 31 | name: string; 32 | registerData: string; 33 | updateData: string; 34 | thumbnailUrl?: string; 35 | } 36 | 37 | interface UserInfo { 38 | userId: string; 39 | nickname: string; 40 | registerDate: string; 41 | } 42 | 43 | type Role = 0 | 1 | 2; 44 | 45 | export interface ParticipantInfo { 46 | id: number; 47 | role: Role; 48 | updateDate: string; 49 | user: UserInfo; 50 | } 51 | 52 | export interface PostThumbnailBody { 53 | file: File; 54 | } 55 | -------------------------------------------------------------------------------- /frontend/src/assets/gif/spinner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web22-BooCrum/99ea3a697c4db9a38a0e049a0b59df9c395c1b79/frontend/src/assets/gif/spinner.gif -------------------------------------------------------------------------------- /frontend/src/assets/icon/boo-crum.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web22-BooCrum/99ea3a697c4db9a38a0e049a0b59df9c395c1b79/frontend/src/assets/icon/boo-crum.png -------------------------------------------------------------------------------- /frontend/src/assets/icon/cursor.cur: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web22-BooCrum/99ea3a697c4db9a38a0e049a0b59df9c395c1b79/frontend/src/assets/icon/cursor.cur -------------------------------------------------------------------------------- /frontend/src/assets/icon/dropdown-active.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/src/assets/icon/dropdown-color.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/src/assets/icon/dropdown-inactive.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/src/assets/icon/export.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web22-BooCrum/99ea3a697c4db9a38a0e049a0b59df9c395c1b79/frontend/src/assets/icon/export.png -------------------------------------------------------------------------------- /frontend/src/assets/icon/github-login.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /frontend/src/assets/icon/plus-workspace.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/src/assets/icon/rename-section.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /frontend/src/assets/icon/toolkit-move-cursor.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/icon/toolkit-select-cursor.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/icon/zoom-in.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/src/assets/icon/zoom-out.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/image/eraser.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web22-BooCrum/99ea3a697c4db9a38a0e049a0b59df9c395c1b79/frontend/src/assets/image/eraser.png -------------------------------------------------------------------------------- /frontend/src/assets/image/error-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web22-BooCrum/99ea3a697c4db9a38a0e049a0b59df9c395c1b79/frontend/src/assets/image/error-image.png -------------------------------------------------------------------------------- /frontend/src/assets/image/hero-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web22-BooCrum/99ea3a697c4db9a38a0e049a0b59df9c395c1b79/frontend/src/assets/image/hero-image.png -------------------------------------------------------------------------------- /frontend/src/assets/image/pen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web22-BooCrum/99ea3a697c4db9a38a0e049a0b59df9c395c1b79/frontend/src/assets/image/pen.png -------------------------------------------------------------------------------- /frontend/src/assets/image/post-it.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 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /frontend/src/assets/index.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /frontend/src/components/context-menu/index.style.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const ContextMeueLayout = styled.div<{ posX: number; posY: number }>` 4 | position: absolute; 5 | top: ${(props) => props.posY}px; 6 | left: ${(props) => props.posX}px; 7 | list-style: none; 8 | padding: 0; 9 | z-index: 100; 10 | `; 11 | -------------------------------------------------------------------------------- /frontend/src/components/context-menu/index.tsx: -------------------------------------------------------------------------------- 1 | import { ContextMeueLayout } from './index.style'; 2 | import { ContextMenuProps } from './index.types'; 3 | function ContextMenu({ isOpen, menuRef, children, posX, posY }: ContextMenuProps) { 4 | return ( 5 | <> 6 | {isOpen && ( 7 | 8 | {children} 9 | 10 | )} 11 | 12 | ); 13 | } 14 | 15 | export default ContextMenu; 16 | -------------------------------------------------------------------------------- /frontend/src/components/context-menu/index.types.ts: -------------------------------------------------------------------------------- 1 | export interface ContextMenuProps { 2 | isOpen: boolean; 3 | menuRef: React.RefObject; 4 | children: React.ReactNode; 5 | posX: number; 6 | posY: number; 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/components/error-modal/index.style.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const ModalContent = styled.div` 4 | display: flex; 5 | height: calc(100% - 60px); 6 | flex-direction: column; 7 | justify-content: center; 8 | align-items: center; 9 | 10 | .error-image { 11 | transform: translate(-15%, 0); 12 | } 13 | 14 | .error-message { 15 | font-weight: 500; 16 | font-size: 24px; 17 | line-height: 33px; 18 | } 19 | 20 | .error-image + .error-message { 21 | margin-top: 30px; 22 | } 23 | `; 24 | -------------------------------------------------------------------------------- /frontend/src/components/error-modal/index.tsx: -------------------------------------------------------------------------------- 1 | import { ModalContent } from './index.style'; 2 | import ErrorImage from '@assets/image/error-image.png'; 3 | import useModal from '@hooks/useModal'; 4 | import Modal from '@components/modal'; 5 | 6 | interface ErrorModalProps { 7 | errorMessage: string; 8 | } 9 | 10 | function ErrorModal({ errorMessage }: ErrorModalProps) { 11 | const { isOpenModal, modalRef, closeModal } = useModal(); 12 | 13 | return ( 14 | 15 | 16 | error-image 17 |
{errorMessage}
18 |
19 |
20 | ); 21 | } 22 | 23 | export default ErrorModal; 24 | -------------------------------------------------------------------------------- /frontend/src/components/github-login-button/index.style.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const ButtonContainer = styled.a` 4 | width: 312px; 5 | background-color: #333; 6 | border-radius: 20px; 7 | border: 1px solid #1a1a1a; 8 | display: flex; 9 | align-items: center; 10 | transition: all 250ms linear; 11 | 12 | color: white; 13 | height: 72px; 14 | text-align: left; 15 | text-decoration: none; 16 | 17 | &:hover { 18 | text-decoration: none; 19 | background-color: #000000; 20 | } 21 | 22 | .text { 23 | font-weight: 600; 24 | font-size: 20px; 25 | line-height: 27px; 26 | margin-left: 25px; 27 | } 28 | 29 | .icon { 30 | width: 40px; 31 | height: 40px; 32 | margin-left: 25px; 33 | text-align: center; 34 | } 35 | `; 36 | -------------------------------------------------------------------------------- /frontend/src/components/github-login-button/index.tsx: -------------------------------------------------------------------------------- 1 | import { ButtonContainer } from './index.style'; 2 | import githubIcon from '@assets/icon/github-login.svg'; 3 | import ErrorModal from '@components/error-modal'; 4 | 5 | function GithubLoginButton() { 6 | return ( 7 | <> 8 | 9 | 10 |
Github로 로그인하기
11 |
12 | 13 | 14 | ); 15 | } 16 | 17 | export default GithubLoginButton; 18 | -------------------------------------------------------------------------------- /frontend/src/components/loading/index.style.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Wrapper = styled.div` 4 | position: absolute; 5 | width: 100vw; 6 | height: 100vh; 7 | top: 0; 8 | left: 0; 9 | z-index: 999; 10 | display: flex; 11 | flex-direction: column; 12 | align-items: center; 13 | justify-content: center; 14 | 15 | .text { 16 | text-align: center; 17 | } 18 | `; 19 | -------------------------------------------------------------------------------- /frontend/src/components/loading/index.tsx: -------------------------------------------------------------------------------- 1 | import { Wrapper } from './index.style'; 2 | import Spinner from '@assets/gif/spinner.gif'; 3 | 4 | function Loading() { 5 | return ( 6 | 7 | spinner 8 |

Loading...

9 |
10 | ); 11 | } 12 | 13 | export default Loading; 14 | -------------------------------------------------------------------------------- /frontend/src/components/logo/index.style.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Wrapper = styled.div` 4 | font-size: 48px; 5 | font-weight: 800; 6 | line-height: 65px; 7 | color: ${({ theme }) => theme.logo}; 8 | letter-spacing: -4.5px; 9 | `; 10 | -------------------------------------------------------------------------------- /frontend/src/components/logo/index.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import { Wrapper } from './index.style'; 3 | 4 | function Logo() { 5 | return BooCrum; 6 | } 7 | 8 | export default memo(Logo); 9 | -------------------------------------------------------------------------------- /frontend/src/components/modal/index.style.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const ModalBackground = styled.div<{ width: number; height: number }>` 4 | position: fixed; 5 | width: 100%; 6 | height: 100%; 7 | top: 0; 8 | left: 0; 9 | background-color: rgba(16, 16, 16, 0.3); 10 | z-index: 5; 11 | 12 | .modal-layout { 13 | position: absolute; 14 | z-index: 10; 15 | width: ${({ width }) => `${width}px`}; 16 | height: ${({ height }) => `${height}px`}; 17 | 18 | top: 50%; 19 | left: 50%; 20 | transform: translate(-50%, -50%); 21 | 22 | background: ${({ theme }) => theme.white}; 23 | box-shadow: 4px 4px 4px rgba(0, 0, 0, 0.25); 24 | border-radius: 8px; 25 | } 26 | 27 | .header { 28 | display: flex; 29 | justify-content: space-between; 30 | align-items: center; 31 | 32 | padding: 10px 20px; 33 | 34 | border-bottom: 1px solid ${({ theme }) => theme.gray_1}; 35 | } 36 | 37 | .modal-title { 38 | font-size: 20px; 39 | font-weight: 500; 40 | line-height: 27px; 41 | 42 | color: ${({ theme }) => theme.black}; 43 | 44 | margin: 0; 45 | } 46 | 47 | .modal-close { 48 | width: 36px; 49 | height: 36px; 50 | 51 | cursor: pointer; 52 | } 53 | `; 54 | -------------------------------------------------------------------------------- /frontend/src/components/modal/index.tsx: -------------------------------------------------------------------------------- 1 | import { ModalBackground } from './index.style'; 2 | import { ModalProps } from './index.types'; 3 | import closeIcon from '@assets/icon/close.svg'; 4 | 5 | function Modal({ isOpen, closeModal, modalRef, width, height, title, children }: ModalProps) { 6 | if (!isOpen) return <>; 7 | return ( 8 | 9 |
10 |
11 |

{title}

12 | close modal 13 |
14 | 15 | {children} 16 |
17 |
18 | ); 19 | } 20 | 21 | export default Modal; 22 | -------------------------------------------------------------------------------- /frontend/src/components/modal/index.types.ts: -------------------------------------------------------------------------------- 1 | export interface ModalProps { 2 | isOpen: boolean; 3 | closeModal: () => void; 4 | modalRef: React.RefObject; 5 | children: React.ReactNode; 6 | title: string; 7 | width: number; 8 | height: number; 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/components/protected-route/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement, useEffect } from 'react'; 2 | import { Navigate } from 'react-router-dom'; 3 | import useAuth from '@hooks/useAuth'; 4 | import Loading from '@components/loading'; 5 | 6 | function ProtectedRoute({ children }: { children: ReactElement }) { 7 | const { isLoading, isAuth, authenticate } = useAuth(); 8 | 9 | useEffect(() => { 10 | authenticate(); 11 | }, []); 12 | 13 | if (isLoading) { 14 | return ; 15 | } 16 | 17 | if (isAuth === false) { 18 | return ; 19 | } 20 | 21 | return children; 22 | } 23 | 24 | export default ProtectedRoute; 25 | -------------------------------------------------------------------------------- /frontend/src/components/toast-message/index.style.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Container = styled.div<{ bold: boolean }>` 4 | background: ${({ bold }) => (bold ? 'black' : 'rgba(16, 16, 16, 0.3)')}; 5 | color: ${({ theme }) => theme.white}; 6 | 7 | padding: 12px 16px; 8 | border-radius: 30px; 9 | 10 | font-size: 12px; 11 | font-weight: 500; 12 | 13 | position: absolute; 14 | bottom: 20px; 15 | left: 50%; 16 | 17 | transform: translate(-50%, 0); 18 | 19 | animation: fadeInMessage 0.5s; 20 | 21 | @keyframes fadeInMessage { 22 | from { 23 | opacity: 0; 24 | } 25 | to { 26 | opacity: 1; 27 | } 28 | } 29 | `; 30 | -------------------------------------------------------------------------------- /frontend/src/components/toast-message/index.tsx: -------------------------------------------------------------------------------- 1 | import { Container } from './index.style'; 2 | 3 | function ToastMessage({ message, bold = false }: { message: string; bold?: boolean }) { 4 | return {message}; 5 | } 6 | export default ToastMessage; 7 | -------------------------------------------------------------------------------- /frontend/src/components/user-profile/index.style.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Wrapper = styled.div` 4 | .user-profile { 5 | width: 32px; 6 | height: 32px; 7 | } 8 | `; 9 | 10 | export const ProfileContainer = styled.div<{ isShow: boolean }>` 11 | position: absolute; 12 | right: 0; 13 | width: 285px; 14 | background-color: white; 15 | box-shadow: 3px 3px 3px rgba(0, 0, 0, 0.25); 16 | z-index: 100; 17 | display: ${({ isShow }) => (isShow ? 'block' : 'none')}; 18 | `; 19 | 20 | export const ProfileItem = styled.div` 21 | display: flex; 22 | align-items: center; 23 | height: 60px; 24 | 25 | cursor: pointer; 26 | 27 | border-bottom: 1px solid #d8d8d8; 28 | :last-child { 29 | border: none; 30 | } 31 | 32 | &:hover { 33 | color: ${({ theme }) => theme.blue_1}; 34 | } 35 | 36 | .icon { 37 | width: 30px; 38 | height: 30px; 39 | padding-left: 16px; 40 | } 41 | 42 | .text { 43 | font-weight: 400; 44 | font-size: 16px; 45 | line-height: 22px; 46 | padding-left: 12px; 47 | } 48 | `; 49 | -------------------------------------------------------------------------------- /frontend/src/components/user-profile/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { Wrapper, ProfileContainer, ProfileItem } from './index.style'; 3 | import userProfileIcon from '@assets/icon/user-profile.svg'; 4 | import logoutIcon from '@assets/icon/logout.svg'; 5 | import useContextMenu from '@hooks/useContextMenu'; 6 | import useAuth from '@hooks/useAuth'; 7 | import { useRecoilValueLoadable } from 'recoil'; 8 | import { userProfileState } from '@context/user'; 9 | 10 | function UserProfile() { 11 | const userProfileLoadable = useRecoilValueLoadable(userProfileState); 12 | const [nickName, setNickName] = useState(''); 13 | const { logout } = useAuth(); 14 | 15 | const { isOpen, menuRef, openContextMenu } = useContextMenu(); 16 | 17 | useEffect(() => { 18 | if (userProfileLoadable.state === 'hasValue') { 19 | const { nickname } = userProfileLoadable.contents; 20 | setNickName(nickname); 21 | } 22 | }, [userProfileLoadable]); 23 | 24 | return ( 25 | 26 | user-profile 27 | 28 | 29 | profile-icon 30 |
{nickName}
31 |
32 | 33 | logout-icon 34 |
Log out
35 |
36 |
37 |
38 | ); 39 | } 40 | 41 | export default UserProfile; 42 | -------------------------------------------------------------------------------- /frontend/src/context/main-workspace.ts: -------------------------------------------------------------------------------- 1 | import { sidebarItems } from '@data/workspace-sidebar'; 2 | import { orderItems } from '@data/workspace-order'; 3 | import { atom } from 'recoil'; 4 | 5 | export const workspaceTypeState = atom({ 6 | key: 'workspaceType', 7 | default: Object.keys(sidebarItems)[0], 8 | }); 9 | 10 | export const workspaceOrderState = atom({ 11 | key: 'workspaceOrder', 12 | default: orderItems[0].id, 13 | }); 14 | -------------------------------------------------------------------------------- /frontend/src/context/user.ts: -------------------------------------------------------------------------------- 1 | import { ProfileData } from '@api/user.types'; 2 | import { atom, selector } from 'recoil'; 3 | import { User } from '@api/user'; 4 | import { workspaceRole } from '@data/workspace-role'; 5 | 6 | export const authState = atom({ 7 | key: 'auth', 8 | default: JSON.parse(localStorage.getItem('auth') || 'false'), 9 | }); 10 | 11 | export const userProfileState = selector({ 12 | key: 'useProfile', 13 | get: async ({ get }): Promise => { 14 | const isAuth = get(authState); 15 | if (!isAuth) return { userId: '', nickname: '', registerDate: '' }; 16 | 17 | return await User.getProfile(); 18 | }, 19 | }); 20 | 21 | export const myInfoInWorkspaceState = atom({ 22 | key: 'infoInWorkspace', 23 | default: { userId: '', nickname: '', color: '', role: workspaceRole.GUEST as Role }, 24 | }); 25 | 26 | type Role = 0 | 1 | 2; 27 | -------------------------------------------------------------------------------- /frontend/src/context/workspace.ts: -------------------------------------------------------------------------------- 1 | import { colorChips } from '@data/workspace-object-color'; 2 | import { toolItems } from '@data/workspace-tool'; 3 | import { Member } from '@pages/workspace/whiteboard-canvas/types'; 4 | import { ParticipantInfo } from '@pages/workspace/member-role/index.type'; 5 | import { atom } from 'recoil'; 6 | 7 | export const cursorState = atom({ 8 | key: 'cursor', 9 | default: { type: toolItems.SELECT, x: 0, y: 0, color: colorChips[0] }, 10 | }); 11 | 12 | export const zoomState = atom({ 13 | key: 'zoom', 14 | default: { percent: 100, event: '' }, 15 | }); 16 | 17 | export const membersState = atom({ 18 | key: 'members', 19 | default: [], 20 | }); 21 | 22 | export const workspaceParticipantsState = atom({ 23 | key: 'participants', 24 | default: [], 25 | }); 26 | -------------------------------------------------------------------------------- /frontend/src/data/workspace-object-color.ts: -------------------------------------------------------------------------------- 1 | export const colorChips: string[] = [ 2 | 'rgb(175, 188, 207)', 3 | 'rgb(252, 163, 151)', 4 | 'rgb(255, 196, 112)', 5 | 'rgb(247, 209, 95)', 6 | 'rgb(121, 210, 151)', 7 | 'rgb(103, 203, 228)', 8 | 'rgb(124, 196, 248)', 9 | 'rgb(209, 168, 255)', 10 | 'rgb(253, 156, 224)', 11 | 'rgb(230, 230, 230)', 12 | ]; 13 | -------------------------------------------------------------------------------- /frontend/src/data/workspace-order.ts: -------------------------------------------------------------------------------- 1 | interface WorkspaceOrderType { 2 | id: number; 3 | description: string; 4 | } 5 | 6 | export const orderItems: WorkspaceOrderType[] = [ 7 | { id: 0, description: 'Last updated' }, 8 | { id: 1, description: 'Last created' }, 9 | { id: 2, description: 'Alphabetically' }, 10 | ]; 11 | 12 | export const orderItemString: string[] = ['last-updated', 'last-created', 'alphabetically']; 13 | -------------------------------------------------------------------------------- /frontend/src/data/workspace-role.ts: -------------------------------------------------------------------------------- 1 | interface RoleType { 2 | [index: string]: number; 3 | } 4 | 5 | export const workspaceRole: RoleType = { 6 | OWNER: 2, 7 | EDITOR: 1, 8 | GUEST: 0, 9 | }; 10 | 11 | type RoleText = 'owner' | 'editor' | 'guest'; 12 | type Role = 2 | 1 | 0; 13 | 14 | interface RoleArrayType { 15 | id: number; 16 | roleText: RoleText; 17 | roleIndex: Role; 18 | } 19 | 20 | export const workspaceRoleArr: RoleArrayType[] = [ 21 | { id: 0, roleText: 'owner', roleIndex: 2 }, 22 | { id: 1, roleText: 'editor', roleIndex: 1 }, 23 | { id: 2, roleText: 'guest', roleIndex: 0 }, 24 | ]; 25 | -------------------------------------------------------------------------------- /frontend/src/data/workspace-sidebar.tsx: -------------------------------------------------------------------------------- 1 | import RecentWorkspace from '@pages/main/recent-workspace'; 2 | import AllWorkspace from '@pages/main/all-workspace'; 3 | 4 | interface SidebarItemType { 5 | [index: string]: { 6 | id: number; 7 | title: string; 8 | component: React.ReactElement; 9 | }; 10 | } 11 | 12 | export const sidebarItems: SidebarItemType = { 13 | recent: { id: 1, title: 'recents', component: }, 14 | workspace: { id: 2, title: 'workspace', component: }, 15 | }; 16 | -------------------------------------------------------------------------------- /frontend/src/data/workspace-tool.ts: -------------------------------------------------------------------------------- 1 | interface WorkspaceToolType { 2 | [index: string]: number; 3 | } 4 | 5 | export const toolItems: WorkspaceToolType = { 6 | SELECT: 0, 7 | MOVE: 1, 8 | PEN: 2, 9 | ERASER: 3, 10 | SECTION: 4, 11 | POST_IT: 5, 12 | }; 13 | -------------------------------------------------------------------------------- /frontend/src/global.style.tsx: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from 'styled-components'; 2 | 3 | export const GlobalStyle = createGlobalStyle` 4 | body { 5 | width: 100%; 6 | height: 100%; 7 | } 8 | h3{ 9 | padding: 0; 10 | } 11 | body, p { 12 | margin: 0; 13 | } 14 | `; 15 | 16 | export const theme = { 17 | logo: '#2071ff', 18 | gray_1: '#d9d9d9', 19 | gray_2: '#d8d8d8', 20 | gray_3: '#777777', 21 | gray_4: '#282828', 22 | blue_1: '#005CFD', 23 | blue_2: '#2071FF', 24 | blue_3: '#5794FF', 25 | red: '#FF4B4B', 26 | black: '#000000', 27 | white: '#ffffff', 28 | }; 29 | -------------------------------------------------------------------------------- /frontend/src/hooks/useAuth.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { authState } from '@context/user'; 3 | import { useRecoilState } from 'recoil'; 4 | import axios from 'axios'; 5 | 6 | function useAuth() { 7 | const [isLoading, setIsLoading] = useState(true); 8 | const [isAuth, setIsAuth] = useRecoilState(authState); 9 | 10 | const authorizate = () => { 11 | setIsAuth(true); 12 | localStorage.setItem('auth', JSON.stringify(true)); 13 | }; 14 | 15 | const deprivate = () => { 16 | setIsAuth(false); 17 | localStorage.setItem('auth', JSON.stringify(false)); 18 | }; 19 | 20 | async function authenticate() { 21 | try { 22 | await axios.get('/api/auth/status'); 23 | authorizate(); 24 | setIsLoading(false); 25 | } catch (error) { 26 | if (axios.isAxiosError(error)) { 27 | if (error.response?.status === 401) { 28 | deprivate(); 29 | } 30 | } 31 | setIsLoading(false); 32 | } 33 | } 34 | 35 | async function login() { 36 | try { 37 | const result = await axios.get('/api/auth/oauth/github', { 38 | withCredentials: true, 39 | }); 40 | // github auth 페이지로 리다이렉트 41 | window.location.href = result.data.url; 42 | return true; 43 | } catch (error) { 44 | if (axios.isAxiosError(error)) { 45 | if (error.response?.status === 400) { 46 | authorizate(); 47 | return true; 48 | } 49 | } 50 | return false; 51 | } 52 | } 53 | 54 | async function logout() { 55 | try { 56 | await axios.put('/api/auth/logout'); 57 | deprivate(); 58 | } catch (error) { 59 | if (axios.isAxiosError(error)) { 60 | if (error.response?.status === 401) { 61 | deprivate(); 62 | } 63 | } 64 | } 65 | } 66 | 67 | return { 68 | isLoading, 69 | isAuth, 70 | authenticate, 71 | login, 72 | logout, 73 | }; 74 | } 75 | 76 | export default useAuth; 77 | -------------------------------------------------------------------------------- /frontend/src/hooks/useContextMenu.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | 3 | function useContextMenu() { 4 | const [isOpen, setIsOpen] = useState(false); 5 | const menuRef = useRef(null); 6 | const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 }); 7 | 8 | useEffect(() => { 9 | document.addEventListener('mousedown', handleOutsideClick); 10 | 11 | return () => { 12 | document.removeEventListener('mousedown', handleOutsideClick); 13 | }; 14 | }, [isOpen]); 15 | 16 | const toggleOpen = (x: number, y: number) => { 17 | setIsOpen(!isOpen); 18 | setMenuPosition({ x: x, y: y }); 19 | }; 20 | 21 | const openContextMenu = () => { 22 | setIsOpen(true); 23 | }; 24 | 25 | const handleOutsideClick = (e: Event) => { 26 | const current = menuRef.current; 27 | if (isOpen && current && !current.contains(e.target as Node)) setIsOpen(false); 28 | }; 29 | 30 | return { isOpen, menuRef, toggleOpen, menuPosition, openContextMenu }; 31 | } 32 | 33 | export default useContextMenu; 34 | -------------------------------------------------------------------------------- /frontend/src/hooks/useModal.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | 3 | function useModal() { 4 | const [isOpenModal, setIsOpenModal] = useState(false); 5 | const modalRef = useRef(null); 6 | 7 | useEffect(() => { 8 | document.addEventListener('mousedown', handleOutsideClick); 9 | 10 | return () => { 11 | document.removeEventListener('mousedown', handleOutsideClick); 12 | }; 13 | }, [isOpenModal]); 14 | 15 | const toggleOpenModal = () => { 16 | setIsOpenModal((prevIsOpenModal) => !prevIsOpenModal); 17 | }; 18 | 19 | const openModal = () => { 20 | setIsOpenModal(true); 21 | }; 22 | 23 | const closeModal = () => { 24 | setIsOpenModal(false); 25 | }; 26 | 27 | const handleOutsideClick = (e: Event) => { 28 | const current = modalRef.current; 29 | if (isOpenModal && current && !current.contains(e.target as Node)) setIsOpenModal(false); 30 | }; 31 | 32 | return { isOpenModal, modalRef, toggleOpenModal, closeModal, openModal }; 33 | } 34 | 35 | export default useModal; 36 | -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import { BrowserRouter } from 'react-router-dom'; 4 | import App from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | import { RecoilRoot } from 'recoil'; 7 | import { GlobalStyle, theme } from './global.style'; 8 | import { ThemeProvider } from 'styled-components'; 9 | 10 | const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); 11 | root.render( 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | 24 | // If you want to start measuring performance in your app, pass a function 25 | // to log results (for example: reportWebVitals(console.log)) 26 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 27 | reportWebVitals(); 28 | -------------------------------------------------------------------------------- /frontend/src/pages/error/index.style.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Container = styled.div` 4 | height: 80vh; 5 | 6 | display: flex; 7 | flex-direction: column; 8 | align-items: center; 9 | justify-content: center; 10 | 11 | .icon { 12 | width: 78px; 13 | height: 78px; 14 | } 15 | 16 | .message { 17 | font-size: 20px; 18 | font-weight: 500; 19 | 20 | margin-top: 16px; 21 | } 22 | `; 23 | -------------------------------------------------------------------------------- /frontend/src/pages/error/index.tsx: -------------------------------------------------------------------------------- 1 | import { Container } from './index.style'; 2 | import booCrum from '@assets/icon/boo-crum.png'; 3 | 4 | function Error() { 5 | return ( 6 | 7 | booCrum icon 8 |

데이터를 불러올 수 없습니다.

9 |
10 | ); 11 | } 12 | 13 | export default Error; 14 | -------------------------------------------------------------------------------- /frontend/src/pages/login/hero-content/index.style.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Container = styled.div` 4 | width: 30%; 5 | height: 100%; 6 | padding: 100px 94px; 7 | box-sizing: border-box; 8 | display: flex; 9 | flex-direction: column; 10 | `; 11 | 12 | export const Heading = styled.div` 13 | font-weight: 700; 14 | font-size: 40px; 15 | line-height: 56px; 16 | margin-bottom: 75px; 17 | 18 | .point_typo { 19 | color: ${({ theme }) => theme.blue_1}; 20 | } 21 | `; 22 | -------------------------------------------------------------------------------- /frontend/src/pages/login/hero-content/index.tsx: -------------------------------------------------------------------------------- 1 | import { Container, Heading } from './index.style'; 2 | import LoginContent from '../login-content'; 3 | 4 | function HeroContent() { 5 | return ( 6 | 7 | 8 | 온라인에서 9 |
10 | 스크럼 회고를 11 |
12 | 진행하는 공간 13 |
14 | 15 |
16 | ); 17 | } 18 | 19 | export default HeroContent; 20 | -------------------------------------------------------------------------------- /frontend/src/pages/login/hero-image/index.style.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Container = styled.div` 4 | width: 70%; 5 | height: 100%; 6 | padding: 0 67px; 7 | display: flex; 8 | align-items: flex-start; 9 | 10 | .hero-image { 11 | width: 100%; 12 | } 13 | `; 14 | -------------------------------------------------------------------------------- /frontend/src/pages/login/hero-image/index.tsx: -------------------------------------------------------------------------------- 1 | import { Container } from './index.style'; 2 | import heroImage from '@assets/image/hero-image.png'; 3 | 4 | function HeroImage() { 5 | return ( 6 | 7 | 8 | 9 | ); 10 | } 11 | 12 | export default HeroImage; 13 | -------------------------------------------------------------------------------- /frontend/src/pages/login/index.style.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Wrapper = styled.div` 4 | height: 100vh; 5 | `; 6 | 7 | export const Header = styled.div` 8 | width: 100%; 9 | height: 160px; 10 | 11 | display: flex; 12 | align-items: center; 13 | 14 | .logo-container { 15 | padding: 0px 104px; 16 | } 17 | `; 18 | 19 | export const Contents = styled.div` 20 | display: flex; 21 | width: 100%; 22 | height: calc(100vh - 160px); 23 | `; 24 | -------------------------------------------------------------------------------- /frontend/src/pages/login/index.tsx: -------------------------------------------------------------------------------- 1 | import Logo from '@components/logo'; 2 | import { Wrapper, Header, Contents } from './index.style'; 3 | import HeroContent from './hero-content'; 4 | import HeroImage from './hero-image'; 5 | import useAuth from '@hooks/useAuth'; 6 | import { Navigate } from 'react-router-dom'; 7 | 8 | function Login() { 9 | const { isAuth } = useAuth(); 10 | 11 | if (isAuth) { 12 | return ; 13 | } 14 | 15 | return ( 16 | 17 |
18 |
19 | 20 |
21 |
22 | 23 | 24 | 25 | 26 |
27 | ); 28 | } 29 | 30 | export default Login; 31 | -------------------------------------------------------------------------------- /frontend/src/pages/login/login-content/index.style.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Container = styled.div` 4 | width: 100%; 5 | `; 6 | 7 | export const Heading = styled.div` 8 | font-weight: 700; 9 | font-size: 48px; 10 | line-height: 65px; 11 | margin-botton: 25px; 12 | `; 13 | -------------------------------------------------------------------------------- /frontend/src/pages/login/login-content/index.tsx: -------------------------------------------------------------------------------- 1 | import { Container, Heading } from './index.style'; 2 | import GithubLoginButton from '@components/github-login-button'; 3 | 4 | function LoginContent() { 5 | return ( 6 | 7 | Sign in 8 | 9 | 10 | ); 11 | } 12 | 13 | export default LoginContent; 14 | -------------------------------------------------------------------------------- /frontend/src/pages/main/all-workspace/index.tsx: -------------------------------------------------------------------------------- 1 | import WorkspaceList from '@pages/main/workspace-list'; 2 | import WorkspaceTemplates from '@pages/main/workspace-template-list'; 3 | 4 | function AllWorkspace() { 5 | return ( 6 | <> 7 | 8 | 9 | 10 | ); 11 | } 12 | 13 | export default AllWorkspace; 14 | -------------------------------------------------------------------------------- /frontend/src/pages/main/contents/index.style.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Wrapper = styled.div` 4 | padding: 40px 50px 15px 50px; 5 | `; 6 | -------------------------------------------------------------------------------- /frontend/src/pages/main/contents/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRecoilValue } from 'recoil'; 2 | import { workspaceTypeState } from '@context/main-workspace'; 3 | import { Wrapper } from './index.style'; 4 | import { sidebarItems } from '@data/workspace-sidebar'; 5 | import Error from '@pages/error'; 6 | 7 | function Contents() { 8 | const workspaceType = useRecoilValue(workspaceTypeState); 9 | 10 | if (sidebarItems[workspaceType]) { 11 | return {sidebarItems[workspaceType].component}; 12 | } 13 | 14 | return ; 15 | } 16 | 17 | export default Contents; 18 | -------------------------------------------------------------------------------- /frontend/src/pages/main/delete-modal/index.style.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const DeleteModalLayout = styled.div` 4 | div { 5 | padding: 0.5em; 6 | font-size: 16px; 7 | margin-left: 0.5em; 8 | } 9 | button { 10 | position: absolute; 11 | bottom: 1em; 12 | right: 1em; 13 | border: none; 14 | background-color: #ff4b4b; 15 | color: white; 16 | font-weight: 600; 17 | padding: 1em; 18 | border-radius: 30px; 19 | cursor: pointer; 20 | } 21 | `; 22 | -------------------------------------------------------------------------------- /frontend/src/pages/main/delete-modal/index.tsx: -------------------------------------------------------------------------------- 1 | import { DeleteModalLayout } from './index.style'; 2 | import { DeleteModalProps } from './index.types'; 3 | 4 | function DeleteModal({ action }: DeleteModalProps) { 5 | const handleClickBtn = () => { 6 | action(); 7 | }; 8 | return ( 9 | 10 |
정말 삭제하시겠습니까?
11 | 12 |
13 | ); 14 | } 15 | 16 | export default DeleteModal; 17 | -------------------------------------------------------------------------------- /frontend/src/pages/main/delete-modal/index.types.ts: -------------------------------------------------------------------------------- 1 | export interface DeleteModalProps { 2 | action: () => void; 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/pages/main/header/index.style.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Container = styled.div` 4 | height: 60px; 5 | border-bottom: 1px solid ${({ theme }) => theme.gray_1}; 6 | 7 | display: flex; 8 | align-items: center; 9 | justify-content: flex-end; 10 | 11 | padding-right: 24px; 12 | 13 | .alarm-icon { 14 | width: 24px; 15 | height: 24px; 16 | 17 | cursor: pointer; 18 | 19 | margin-right: 20px; 20 | } 21 | `; 22 | -------------------------------------------------------------------------------- /frontend/src/pages/main/header/index.tsx: -------------------------------------------------------------------------------- 1 | import UserProfile from '@components/user-profile'; 2 | import { Container } from './index.style'; 3 | 4 | function Header() { 5 | return ( 6 | 7 | 8 | 9 | ); 10 | } 11 | 12 | export default Header; 13 | -------------------------------------------------------------------------------- /frontend/src/pages/main/index.style.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Wrapper = styled.div` 4 | display: flex; 5 | 6 | .workspace-container { 7 | width: calc(100vw - 286px); 8 | border-left: 1px solid ${({ theme }) => theme.gray_1}; 9 | } 10 | `; 11 | -------------------------------------------------------------------------------- /frontend/src/pages/main/index.tsx: -------------------------------------------------------------------------------- 1 | import Contents from './contents'; 2 | import Header from './header'; 3 | import { Wrapper } from './index.style'; 4 | import Sidebar from './sidebar'; 5 | 6 | function Main() { 7 | return ( 8 | 9 | 10 |
11 |
12 | 13 |
14 |
15 | ); 16 | } 17 | 18 | export default Main; 19 | -------------------------------------------------------------------------------- /frontend/src/pages/main/order-dropdown/index.style.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Dropdown = styled.div` 4 | position: relative; 5 | z-index: 5; 6 | 7 | .dropdown-button { 8 | width: 148px; 9 | 10 | font-size: 12px; 11 | font-weight: 400; 12 | line-height: 16px; 13 | 14 | border: 1px solid ${({ theme }) => theme.gray_2}; 15 | border-radius: 5px; 16 | 17 | padding: 9px 16px; 18 | 19 | display: flex; 20 | justify-content: space-between; 21 | align-items: center; 22 | 23 | cursor: pointer; 24 | } 25 | 26 | .dropdown-icon { 27 | width: 14px; 28 | height: 7px; 29 | } 30 | 31 | .dropdown-container { 32 | width: 140px; 33 | padding: 18px 20px; 34 | 35 | border: 1px solid ${({ theme }) => theme.gray_2}; 36 | border-radius: 5px; 37 | 38 | position: absolute; 39 | 40 | top: 40px; 41 | 42 | background: ${({ theme }) => theme.white}; 43 | } 44 | `; 45 | 46 | export const Description = styled.p<{ isSelected: boolean }>` 47 | font-size: 12px; 48 | font-weight: 400; 49 | line-height: 16px; 50 | 51 | color: ${({ isSelected, theme }) => (isSelected ? theme.blue_1 : theme.black)}; 52 | 53 | cursor: pointer; 54 | 55 | & + & { 56 | margin-top: 12px; 57 | } 58 | `; 59 | -------------------------------------------------------------------------------- /frontend/src/pages/main/order-dropdown/index.tsx: -------------------------------------------------------------------------------- 1 | import { workspaceOrderState } from '@context/main-workspace'; 2 | import { orderItems } from '@data/workspace-order'; 3 | import { useRecoilState } from 'recoil'; 4 | import { Dropdown, Description } from './index.style'; 5 | import useModal from '@hooks/useModal'; 6 | import dropdownActive from '@assets/icon/dropdown-active.svg'; 7 | import dropdownInActive from '@assets/icon/dropdown-inactive.svg'; 8 | 9 | function OrderDropdown() { 10 | const [orderType, setOrderType] = useRecoilState(workspaceOrderState); 11 | const { modalRef, isOpenModal, toggleOpenModal } = useModal(); 12 | 13 | const handleOrderType = (id: number) => { 14 | setOrderType(id); 15 | toggleOpenModal(); 16 | }; 17 | 18 | return ( 19 | 20 |
21 |

{orderItems[orderType].description}

22 | dropdown button 23 |
24 | {isOpenModal && ( 25 |
26 | {orderItems.map((type) => ( 27 | handleOrderType(type.id)}> 28 | {type.description} 29 | 30 | ))} 31 |
32 | )} 33 |
34 | ); 35 | } 36 | 37 | export default OrderDropdown; 38 | -------------------------------------------------------------------------------- /frontend/src/pages/main/recent-workspace/index.tsx: -------------------------------------------------------------------------------- 1 | import WorkspaceList from '@pages/main/workspace-list'; 2 | 3 | function RecentWorkspace() { 4 | return ; 5 | } 6 | 7 | export default RecentWorkspace; 8 | -------------------------------------------------------------------------------- /frontend/src/pages/main/rename-modal/index.style.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const RenameModalLayout = styled.div` 4 | input { 5 | margin: 0.5em; 6 | border: none; 7 | border-bottom: 1px solid #d8d8d8; 8 | padding: 0.3em; 9 | font-size: 16px; 10 | outline: none; 11 | margin-left: 1em; 12 | } 13 | button { 14 | position: absolute; 15 | bottom: 1em; 16 | right: 1em; 17 | border: none; 18 | background-color: #2071ff; 19 | color: white; 20 | font-weight: 600; 21 | padding: 1em; 22 | border-radius: 30px; 23 | cursor: pointer; 24 | } 25 | `; 26 | -------------------------------------------------------------------------------- /frontend/src/pages/main/rename-modal/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { RenameModalLayout } from './index.style'; 3 | import { RenameModalProps } from './index.types'; 4 | 5 | function RenameModal({ action, workspaceName }: RenameModalProps) { 6 | const [newWorkspaceName, setNewWorkspaceName] = useState(workspaceName); 7 | const handleClickBtn = () => { 8 | action(newWorkspaceName); 9 | }; 10 | const onChange: React.ChangeEventHandler = (e) => { 11 | setNewWorkspaceName(e.target.value); 12 | }; 13 | return ( 14 | 15 | 16 | 17 | 18 | ); 19 | } 20 | 21 | export default RenameModal; 22 | -------------------------------------------------------------------------------- /frontend/src/pages/main/rename-modal/index.types.ts: -------------------------------------------------------------------------------- 1 | export interface RenameModalProps { 2 | action: (workspaceName: string) => void; 3 | workspaceName: string; 4 | } 5 | -------------------------------------------------------------------------------- /frontend/src/pages/main/sidebar/index.style.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Container = styled.div` 4 | height: calc(100vh - 32px); 5 | 6 | padding: 32px 62px 0 57px; 7 | 8 | .sidebar-list { 9 | font-size: 24px; 10 | font-weight: 500; 11 | line-height: 33px; 12 | letter-spacing: -0.03px; 13 | 14 | margin: 60px 5px; 15 | } 16 | `; 17 | 18 | export const SidebarItem = styled.p<{ isSelected: boolean }>` 19 | cursor: pointer; 20 | 21 | color: ${({ isSelected, theme }) => (isSelected ? theme.blue_1 : theme.black)}; 22 | 23 | & + & { 24 | margin-top: 30px; 25 | } 26 | `; 27 | -------------------------------------------------------------------------------- /frontend/src/pages/main/sidebar/index.tsx: -------------------------------------------------------------------------------- 1 | import Logo from '@components/logo'; 2 | import { Container, SidebarItem } from './index.style'; 3 | import { useRecoilState } from 'recoil'; 4 | import { workspaceTypeState } from '@context/main-workspace'; 5 | import { sidebarItems } from '@data/workspace-sidebar'; 6 | 7 | function Sidebar() { 8 | const [workspaceType, setWorkspaceType] = useRecoilState(workspaceTypeState); 9 | 10 | const handleWorkspaceType = (id: string) => { 11 | setWorkspaceType(id); 12 | }; 13 | 14 | return ( 15 | 16 | 17 |
18 | {Object.keys(sidebarItems).map((key) => ( 19 | handleWorkspaceType(key)} 23 | > 24 | {sidebarItems[key].title} 25 | 26 | ))} 27 |
28 |
29 | ); 30 | } 31 | 32 | export default Sidebar; 33 | -------------------------------------------------------------------------------- /frontend/src/pages/main/workspace-card/index.style.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const CardLayout = styled.div` 4 | position: relative; 5 | width: 200px; 6 | border: none; 7 | border-radius: 10px; 8 | box-shadow: 3px 4px 4px rgba(0, 0, 0, 0.25); 9 | overflow: hidden; 10 | transition: all 0.2s; 11 | .card-thumbnail { 12 | display: block; 13 | height: 200px; 14 | width: 100%; 15 | background-color: #d8d8d8; 16 | object-fit: cover; 17 | object-position: left; 18 | } 19 | .card-info { 20 | padding: 0.5rem; 21 | } 22 | .card-title { 23 | font-size: 16px; 24 | font-weight: 600; 25 | } 26 | .card-timestamp { 27 | font-size: 12px; 28 | color: #515151; 29 | } 30 | :hover { 31 | box-shadow: 7px 7px 4px rgba(0, 0, 0, 0.25); 32 | } 33 | `; 34 | -------------------------------------------------------------------------------- /frontend/src/pages/main/workspace-card/index.tsx: -------------------------------------------------------------------------------- 1 | import ContextMenu from '@components/context-menu'; 2 | import useContextMenu from '@hooks/useContextMenu'; 3 | import { useNavigate } from 'react-router-dom'; 4 | import WorkspaceMenu from '../workspace-menu'; 5 | import { CardLayout } from './index.style'; 6 | import { WorkspaceCardProps } from './index.type'; 7 | 8 | function WorkspaceCard({ workspaceId, role, title, timestamp, imgSrc, setWorkspaceList }: WorkspaceCardProps) { 9 | const { isOpen, menuRef, toggleOpen, menuPosition } = useContextMenu(); 10 | const navigate = useNavigate(); 11 | 12 | const openContextMenu: React.MouseEventHandler = (e) => { 13 | e.preventDefault(); 14 | toggleOpen(e.pageX, e.pageY); 15 | }; 16 | 17 | const handleRouting = () => { 18 | navigate(`/workspace/${workspaceId}`, { state: { workspaceId, name: title } }); 19 | }; 20 | 21 | return ( 22 |
23 | 24 | workspace thumbnail 25 |
26 |
{title}
27 |
{timestamp}
28 |
29 |
30 | 31 | 37 | 38 |
39 | ); 40 | } 41 | 42 | export default WorkspaceCard; 43 | -------------------------------------------------------------------------------- /frontend/src/pages/main/workspace-card/index.type.ts: -------------------------------------------------------------------------------- 1 | export interface WorkspaceCardProps { 2 | role: number; 3 | workspaceId: string; 4 | title: string; 5 | timestamp: string; 6 | imgSrc: string; 7 | setWorkspaceList: () => void; 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/pages/main/workspace-list/index.style.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const TitleContainer = styled.div` 4 | display: flex; 5 | justify-content: space-between; 6 | `; 7 | 8 | export const WorkspaceListContainer = styled.div` 9 | display: flex; 10 | gap: 32px; 11 | flex-basis: 150px; 12 | flex-grow: 0; 13 | flex-wrap: wrap; 14 | `; 15 | 16 | export const Title = styled.p` 17 | font-size: 32px; 18 | font-weight: 700; 19 | line-height: 44px; 20 | 21 | margin-bottom: 28px; 22 | `; 23 | -------------------------------------------------------------------------------- /frontend/src/pages/main/workspace-list/index.type.ts: -------------------------------------------------------------------------------- 1 | export interface WorkspaceCardType { 2 | role: number; 3 | workspace: { 4 | workspaceId: string; 5 | description: null; 6 | name: string; 7 | registerDate: string; 8 | updateDate: string; 9 | thumbnailUrl: string; 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/pages/main/workspace-menu/index.style.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const WorkspaceMenuList = styled.ul` 4 | margin: 0; 5 | padding: 0; 6 | list-style: none; 7 | cursor: pointer; 8 | width: 100px; 9 | background: ${({ theme }) => theme.white}; 10 | 11 | box-shadow: 3px 3px 3px rgba(0, 0, 0, 0.25); 12 | `; 13 | 14 | export const WorkspaceMenuItem = styled.li` 15 | font-size: 16px; 16 | border-bottom: 1px solid #d8d8d8; 17 | padding: 5px; 18 | :last-child { 19 | border: none; 20 | } 21 | :hover { 22 | color: ${({ theme }) => theme.blue_2}; 23 | } 24 | `; 25 | -------------------------------------------------------------------------------- /frontend/src/pages/main/workspace-menu/index.tsx: -------------------------------------------------------------------------------- 1 | import Modal from '@components/modal'; 2 | import useModal from '@hooks/useModal'; 3 | import { useState } from 'react'; 4 | 5 | import { WorkspaceMenuItem, WorkspaceMenuList } from './index.style'; 6 | import { WorkspaceMenuProps } from './index.types'; 7 | import DeleteModal from '../delete-modal'; 8 | import RenameModal from '../rename-modal'; 9 | import { Workspace } from '@api/workspace'; 10 | import { workspaceRole } from '@data/workspace-role'; 11 | import { useNavigate } from 'react-router'; 12 | 13 | function WorkspaceMenu({ workspaceId, role, workspaceName, setWorkspaceList }: WorkspaceMenuProps) { 14 | const { isOpenModal, modalRef, toggleOpenModal, closeModal } = useModal(); 15 | const [modalContent, setModalContent] = useState(<>); 16 | const [title, setTitle] = useState(''); 17 | const navigate = useNavigate(); 18 | 19 | const handleRouting = () => { 20 | navigate(`/workspace/${workspaceId}`, { state: { workspaceId, name: title } }); 21 | }; 22 | 23 | const openReanmeModal = () => { 24 | const renameWorkspace = async (workspaceName: string) => { 25 | toggleOpenModal(); 26 | await Workspace.patchWorkspace(workspaceId, { name: workspaceName }); 27 | setWorkspaceList(); 28 | }; 29 | toggleOpenModal(); 30 | setTitle('Rename'); 31 | setModalContent(); 32 | }; 33 | const openDeleteModal = () => { 34 | const deleteWorkspace = async () => { 35 | toggleOpenModal(); 36 | await Workspace.deleteWorkspace(workspaceId); 37 | setWorkspaceList(); 38 | }; 39 | toggleOpenModal(); 40 | setTitle('Delete'); 41 | setModalContent(); 42 | }; 43 | return ( 44 | <> 45 | 46 | Open 47 | {role === workspaceRole.OWNER && ( 48 | <> 49 | Rename 50 | Delete 51 | 52 | )} 53 | 54 | 55 | {modalContent} 56 | 57 | 58 | ); 59 | } 60 | 61 | export default WorkspaceMenu; 62 | -------------------------------------------------------------------------------- /frontend/src/pages/main/workspace-menu/index.types.ts: -------------------------------------------------------------------------------- 1 | export interface WorkspaceMenuProps { 2 | role: number; 3 | workspaceId: string; 4 | workspaceName: string; 5 | setWorkspaceList: () => void; 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/pages/main/workspace-template-list/index.style.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Container = styled.div` 4 | margin-bottom: 24px; 5 | 6 | .title { 7 | font-size: 32px; 8 | font-weight: 700; 9 | line-height: 44px; 10 | 11 | margin-bottom: 28px; 12 | } 13 | 14 | .template-list { 15 | display: flex; 16 | flex-wrap: nowrap; 17 | overflow-x: auto; 18 | } 19 | `; 20 | -------------------------------------------------------------------------------- /frontend/src/pages/main/workspace-template-list/index.tsx: -------------------------------------------------------------------------------- 1 | import { Container } from './index.style'; 2 | import WorkspaceTemplate from '@pages/main/workspace-template'; 3 | import { TemplateType } from './index.type'; 4 | import { useEffect, useState } from 'react'; 5 | import { Workspace } from '@api/workspace'; 6 | 7 | const newBoard: TemplateType = { 8 | templateId: '0', 9 | templateName: 'New board', 10 | templateThumbnailUrl: null, 11 | isNewTemplate: true, 12 | }; 13 | 14 | function WorkspaceTemplates() { 15 | const [templates, setTemplates] = useState([]); 16 | 17 | useEffect(() => { 18 | async function getTemplates() { 19 | const result = await Workspace.getTemplates(); 20 | setTemplates(result); 21 | } 22 | 23 | getTemplates(); 24 | }, []); 25 | 26 | return ( 27 | 28 |

Create a workspace

29 |
30 | 31 | {templates.map((template) => ( 32 | 33 | ))} 34 |
35 |
36 | ); 37 | } 38 | 39 | export default WorkspaceTemplates; 40 | -------------------------------------------------------------------------------- /frontend/src/pages/main/workspace-template-list/index.type.ts: -------------------------------------------------------------------------------- 1 | export interface TemplateType { 2 | templateId: string; 3 | templateName: string; 4 | templateThumbnailUrl: string | null; 5 | isNewTemplate?: boolean; 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/pages/main/workspace-template/index.style.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Template = styled.div` 4 | cursor: pointer; 5 | 6 | :hover { 7 | .template-card { 8 | box-shadow: 3px 3px 10px ${({ theme }) => theme.gray_1}; 9 | } 10 | } 11 | 12 | & + & { 13 | margin-left: 24px; 14 | } 15 | 16 | .template-card { 17 | width: 150px; 18 | height: 100px; 19 | 20 | border-radius: 10px; 21 | 22 | background: ${({ theme }) => theme.blue_3}; 23 | 24 | display: flex; 25 | justify-content: center; 26 | align-items: center; 27 | 28 | transition: all 0.2s; 29 | } 30 | 31 | .preview { 32 | width: 150px; 33 | height: 100px; 34 | 35 | border-radius: 10px; 36 | } 37 | 38 | .new-icon { 39 | width: 30px; 40 | height: 30px; 41 | } 42 | 43 | .template-title { 44 | font-size: 15px; 45 | font-weight: 600; 46 | line-height: 20px; 47 | 48 | width: 150px; 49 | word-break: keep-all; 50 | 51 | margin-top: 12px; 52 | } 53 | `; 54 | -------------------------------------------------------------------------------- /frontend/src/pages/main/workspace-template/index.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigate } from 'react-router-dom'; 2 | import plusWorkspace from '@assets/icon/plus-workspace.svg'; 3 | import { Template } from './index.style'; 4 | import { Workspace } from '@api/workspace'; 5 | import { TemplateType } from './index.type'; 6 | 7 | function WorkspaceTemplate({ template }: { template: TemplateType }) { 8 | const navigate = useNavigate(); 9 | 10 | const handleWorkspaceRouting = async () => { 11 | const workspace = await Workspace.postWorkspace(!template.isNewTemplate ? template.templateId : '', {}); 12 | navigate(`/workspace/${workspace.workspaceId}`); 13 | }; 14 | 15 | return ( 16 | 26 | ); 27 | } 28 | 29 | export default WorkspaceTemplate; 30 | -------------------------------------------------------------------------------- /frontend/src/pages/main/workspace-template/index.type.ts: -------------------------------------------------------------------------------- 1 | export interface TemplateType { 2 | templateId: string; 3 | templateName: string; 4 | templateThumbnailUrl: string | null; 5 | isNewTemplate?: boolean; 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/current-user-list/index.style.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Container = styled.div` 4 | margin-right: 12px; 5 | display: flex; 6 | 7 | position: relative; 8 | 9 | .user-icon { 10 | width: 30px; 11 | height: 30px; 12 | 13 | margin-top: 6px; 14 | } 15 | `; 16 | 17 | export const UserDetail = styled.div` 18 | position: absolute; 19 | top: 40px; 20 | 21 | background: ${({ theme }) => theme.white}; 22 | padding: 10px 16px; 23 | border-radius: 12px; 24 | 25 | font-size: 14px; 26 | font-weight: 500; 27 | 28 | display: none; 29 | 30 | box-shadow: 1px 1px 3px rgba(0, 0, 0, 0.25); 31 | 32 | white-space: nowrap; 33 | `; 34 | 35 | export const UserInfo = styled.div<{ color: string }>` 36 | display: flex; 37 | justify-content: center; 38 | align-items: center; 39 | 40 | width: 30px; 41 | height: 30px; 42 | 43 | background: ${({ color }) => color}; 44 | border-radius: 30px; 45 | overflow: hidden; 46 | 47 | cursor: pointer; 48 | 49 | font-weight: 700; 50 | 51 | & + & { 52 | margin-left: 8px; 53 | } 54 | 55 | :hover { 56 | ${UserDetail} { 57 | display: block; 58 | } 59 | } 60 | `; 61 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/current-user-list/index.tsx: -------------------------------------------------------------------------------- 1 | import { Container, UserDetail, UserInfo } from './index.style'; 2 | import { useRecoilValue } from 'recoil'; 3 | import { membersState } from '@context/workspace'; 4 | import userIcon from '@assets/icon/user-icon.svg'; 5 | 6 | function UserList() { 7 | const members = useRecoilValue(membersState); 8 | 9 | return ( 10 | 11 | {members.slice(0, 5).map((member) => ( 12 | 13 |
14 | user profile 15 |
16 | {member.nickname} 17 |
18 | ))} 19 | {members.length > 5 && ( 20 | 21 | ... 22 | +{members.length - 5}명의 사용자 23 | 24 | )} 25 |
26 | ); 27 | } 28 | 29 | export default UserList; 30 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/export-modal/index.style.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const ModalContent = styled.div` 4 | .export-modal-text { 5 | margin-top: 32px; 6 | font-size: 20px; 7 | margin-left: 16px; 8 | font-weight: 400; 9 | } 10 | `; 11 | 12 | export const ExportButton = styled.button` 13 | position: absolute; 14 | bottom: 1em; 15 | right: 1em; 16 | border: none; 17 | background-color: #2071ff; 18 | color: white; 19 | font-weight: 600; 20 | padding: 1em; 21 | border-radius: 30px; 22 | 23 | :disabled { 24 | background-color: lightgrey; 25 | } 26 | `; 27 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/export-modal/index.tsx: -------------------------------------------------------------------------------- 1 | import Modal from '@components/modal'; 2 | import ToastMessage from '@components/toast-message'; 3 | import useModal from '@hooks/useModal'; 4 | import { useState, useEffect } from 'react'; 5 | import { ModalContent, ExportButton } from './index.style'; 6 | import { calcCanvasFullWidthAndHeight } from '../../../utils/fabric.utils'; 7 | import { useParams } from 'react-router-dom'; 8 | 9 | function ExportModal({ canvas }: { canvas: React.MutableRefObject }) { 10 | const { isOpenModal, modalRef, closeModal, openModal } = useModal(); 11 | const [isExporting, setIsExporting] = useState(false); 12 | const [openToast, setOpenToast] = useState(false); 13 | const { workspaceId } = useParams(); 14 | 15 | const handleClickExportButton = () => { 16 | if (!canvas.current) return; 17 | // 막기 18 | setIsExporting(true); 19 | setOpenToast(true); 20 | const coords = calcCanvasFullWidthAndHeight(canvas.current); 21 | let dataUrl; 22 | if (!coords.left || !coords.right || !coords.top || !coords.bottom) { 23 | dataUrl = canvas.current.toDataURL(); 24 | } else { 25 | dataUrl = canvas.current.toDataURL({ 26 | left: coords.left - 60, 27 | top: coords.top - 60, 28 | width: coords.right - coords.left + 120, 29 | height: coords.bottom - coords.top + 120, 30 | }); 31 | } 32 | closeModal(); 33 | setIsExporting(false); 34 | setOpenToast(false); 35 | const link = document.createElement('a'); 36 | link.href = dataUrl; 37 | link.download = 'boocrum_' + (workspaceId || 'export'); 38 | document.body.appendChild(link); 39 | link.click(); 40 | document.body.removeChild(link); 41 | }; 42 | 43 | const openExportModal = () => { 44 | openModal(); 45 | }; 46 | 47 | useEffect(() => { 48 | document.addEventListener('export:open', openExportModal); 49 | 50 | return () => { 51 | document.removeEventListener('export:open', openExportModal); 52 | }; 53 | }, []); 54 | 55 | return ( 56 | 57 | 58 |
파일을 추출하시겠습니까?
59 | 60 | Export 61 | 62 |
63 | {openToast && } 64 |
65 | ); 66 | } 67 | 68 | export default ExportModal; 69 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/header/index.style.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Container = styled.div` 4 | position: fixed; 5 | width: 100%; 6 | height: 56px; 7 | z-index: 5; 8 | 9 | border-bottom: 1px solid ${({ theme }) => theme.gray_1}; 10 | background-color: white; 11 | display: flex; 12 | align-items: center; 13 | 14 | .title { 15 | font-size: 20px; 16 | font-weight: 400; 17 | line-height: 27px; 18 | 19 | text-align: center; 20 | 21 | flex: 1; 22 | } 23 | `; 24 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/header/index.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useState, useEffect } from 'react'; 2 | import LeftSide from '../left-side'; 3 | import RightSide from '../right-side'; 4 | import { Container } from './index.style'; 5 | import { Workspace } from '@api/workspace'; 6 | import { HeaderProps } from './index.type'; 7 | 8 | function Header({ workspaceId, openShareModal }: HeaderProps) { 9 | const [workspace, setWorkspaceName] = useState(''); 10 | 11 | useEffect(() => { 12 | async function getMetaData() { 13 | const { name } = await Workspace.getWorkspaceMetadata(workspaceId); 14 | setWorkspaceName(name); 15 | } 16 | getMetaData(); 17 | }, []); 18 | 19 | return ( 20 | 21 | 22 |

{workspace}

23 | 24 |
25 | ); 26 | } 27 | 28 | export default memo(Header); 29 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/header/index.type.ts: -------------------------------------------------------------------------------- 1 | export interface HeaderProps { 2 | workspaceId: string; 3 | openShareModal: () => void; 4 | } 5 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/index.tsx: -------------------------------------------------------------------------------- 1 | import { useParams, Navigate } from 'react-router-dom'; 2 | import WhiteboardCanvas from './whiteboard-canvas'; 3 | import { useEffect } from 'react'; 4 | import Loading from '@components/loading'; 5 | import useAuth from '@hooks/useAuth'; 6 | import Layout from './layout'; 7 | 8 | function Workspace() { 9 | const { isLoading, authenticate } = useAuth(); 10 | const { workspaceId } = useParams(); 11 | 12 | useEffect(() => { 13 | authenticate(); 14 | }, []); 15 | 16 | if (isLoading) { 17 | return ; 18 | } 19 | 20 | if (workspaceId === undefined) { 21 | return ; 22 | } 23 | 24 | return ( 25 | <> 26 | 27 | 28 | 29 | ); 30 | } 31 | 32 | export default Workspace; 33 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/layout/index.tsx: -------------------------------------------------------------------------------- 1 | import Modal from '@components/modal'; 2 | import { myInfoInWorkspaceState } from '@context/user'; 3 | import { workspaceRole } from '@data/workspace-role'; 4 | import useModal from '@hooks/useModal'; 5 | import { useCallback, useState } from 'react'; 6 | import { useRecoilValue } from 'recoil'; 7 | import Header from '../header'; 8 | import ShareModal from '../share-modal'; 9 | import Toolkit from '../toolkit'; 10 | 11 | function Layout({ workspaceId }: { workspaceId: string }) { 12 | const { isOpenModal, openModal, modalRef, closeModal } = useModal(); 13 | const [modalContent, setModalContent] = useState(<>); 14 | const myInfoInWorkspace = useRecoilValue(myInfoInWorkspaceState); 15 | 16 | const openShareModal = useCallback(() => { 17 | openModal(); 18 | setModalContent(); 19 | }, []); 20 | 21 | return ( 22 | <> 23 |
24 | {myInfoInWorkspace.role !== workspaceRole.GUEST && } 25 | 26 | 27 | {modalContent} 28 | 29 | 30 | ); 31 | } 32 | 33 | export default Layout; 34 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/left-side/index.style.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Container = styled.div` 4 | flex: 1; 5 | display: flex; 6 | 7 | margin-left: 22px; 8 | align-items: center; 9 | vertical-align: center; 10 | 11 | .logo { 12 | font-size: 32px; 13 | font-weight: 800; 14 | 15 | color: ${({ theme }) => theme.logo}; 16 | 17 | cursor: pointer; 18 | } 19 | 20 | .export { 21 | width: 24px; 22 | height: 28px; 23 | margin: 3px 0 0 20px; 24 | 25 | cursor: pointer; 26 | } 27 | `; 28 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/left-side/index.tsx: -------------------------------------------------------------------------------- 1 | import { Container } from './index.style'; 2 | import exportIcon from '@assets/icon/export.png'; 3 | import { useNavigate } from 'react-router-dom'; 4 | 5 | function LeftSide() { 6 | const navigate = useNavigate(); 7 | 8 | const handleClickExportButton = () => { 9 | const customEvent = new CustomEvent('export:open'); 10 | document.dispatchEvent(customEvent); 11 | }; 12 | 13 | return ( 14 | 15 |

navigate('/')}> 16 | B 17 |

18 | export canvas 19 |
20 | ); 21 | } 22 | 23 | export default LeftSide; 24 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/member-role/index.style.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Container = styled.div` 4 | display: flex; 5 | justify-content: space-between; 6 | 7 | position: relative; 8 | 9 | padding: 16px 20px; 10 | 11 | .info { 12 | display: flex; 13 | align-items: center; 14 | } 15 | 16 | .profile { 17 | width: 32px; 18 | height: 32px; 19 | } 20 | 21 | .role-setting { 22 | display: flex; 23 | align-items: center; 24 | } 25 | 26 | .role-selected { 27 | font-size: 14px; 28 | line-height: 22px; 29 | font-weight: 400; 30 | 31 | color: ${({ theme }) => theme.black}; 32 | } 33 | 34 | .name { 35 | font-size: 16px; 36 | line-height: 22px; 37 | font-weight: 400; 38 | 39 | color: ${({ theme }) => theme.black}; 40 | 41 | margin-left: 12px; 42 | } 43 | 44 | .dropdown { 45 | width: 10px; 46 | margin-left: 8px; 47 | 48 | cursor: pointer; 49 | } 50 | `; 51 | 52 | export const RoleEditMenu = styled.div` 53 | position: absolute; 54 | top: 40px; 55 | left: 512px; 56 | 57 | z-index: 5; 58 | 59 | padding: 10px 16px; 60 | background: ${({ theme }) => theme.white}; 61 | border-radius: 10px; 62 | 63 | box-shadow: 1px 1px 3px rgba(0, 0, 0, 0.25); 64 | 65 | .role { 66 | font-size: 14px; 67 | line-height: 22px; 68 | font-weight: 400; 69 | 70 | color: ${({ theme }) => theme.black}; 71 | 72 | cursor: pointer; 73 | 74 | + .role { 75 | margin-top: 2px; 76 | } 77 | } 78 | `; 79 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/member-role/index.tsx: -------------------------------------------------------------------------------- 1 | import { Container, RoleEditMenu } from './index.style'; 2 | import userProfile from '@assets/icon/user-profile.svg'; 3 | import dropdownIcon from '@assets/icon/dropdown-inactive.svg'; 4 | import { convertRole } from '@utils/member-role.utils'; 5 | import { MemberRoleProps, Role } from './index.type'; 6 | import useModal from '@hooks/useModal'; 7 | import { workspaceRole, workspaceRoleArr } from '@data/workspace-role'; 8 | import { useRecoilValue } from 'recoil'; 9 | import { myInfoInWorkspaceState } from '@context/user'; 10 | 11 | function MemberRole({ participant, handleRole }: MemberRoleProps) { 12 | const { modalRef, isOpenModal, toggleOpenModal, closeModal } = useModal(); 13 | const myInfoInWorkspace = useRecoilValue(myInfoInWorkspaceState); 14 | 15 | const handleMemberRole = (role: Role) => { 16 | handleRole(participant.user.userId, role); 17 | closeModal(); 18 | }; 19 | 20 | return ( 21 | 22 |
23 | participant profile 24 |

{participant.user.nickname}

25 |
26 |
27 |
28 |

{convertRole(participant.role)}

29 | {myInfoInWorkspace.role === workspaceRole.OWNER && ( 30 | role dropdown 31 | )} 32 |
33 | 34 | {isOpenModal && ( 35 | 36 | {workspaceRoleArr.map((role) => ( 37 |

handleMemberRole(role.roleIndex)}> 38 | {role.roleText} 39 |

40 | ))} 41 |
42 | )} 43 |
44 |
45 | ); 46 | } 47 | 48 | export default MemberRole; 49 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/member-role/index.type.ts: -------------------------------------------------------------------------------- 1 | interface UserInfo { 2 | userId: string; 3 | nickname: string; 4 | registerDate: string; 5 | } 6 | 7 | export type Role = 0 | 1 | 2; 8 | 9 | export interface ParticipantInfo { 10 | id: number; 11 | role: Role; 12 | updateDate: string; 13 | user: UserInfo; 14 | } 15 | 16 | export interface MemberRoleProps { 17 | participant: ParticipantInfo; 18 | handleRole: (userId: string, role: Role) => void; 19 | } 20 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/object-edit-menu/index.style.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Container = styled.div` 4 | height: 32px; 5 | position: relative; 6 | display: flex; 7 | 8 | border-radius: 4px; 9 | 10 | background: ${({ theme }) => theme.gray_4}; 11 | `; 12 | 13 | export const ColorSelect = styled.div<{ color: string }>` 14 | display: flex; 15 | align-items: center; 16 | 17 | cursor: pointer; 18 | padding: 0 6px 0 10px; 19 | 20 | .selected-color { 21 | background: ${({ color }) => color}; 22 | border-radius: 30px; 23 | 24 | width: 16px; 25 | height: 16px; 26 | 27 | margin-right: 4px; 28 | } 29 | `; 30 | 31 | export const FontSize = styled.input<{ selected: boolean }>` 32 | background: ${({ theme }) => theme.gray_4}; 33 | width: 40px; 34 | 35 | padding: 0 10px 0 6px; 36 | border: none; 37 | border-radius: 4px; 38 | border-left: 1px solid ${({ theme }) => theme.gray_3}; 39 | 40 | font-size: 12px; 41 | color: ${({ theme }) => theme.white}; 42 | 43 | outline: none; 44 | `; 45 | 46 | export const ColorChip = styled.div` 47 | padding: 8px 16px; 48 | border-radius: 10px; 49 | 50 | background: ${({ theme }) => theme.gray_4}; 51 | 52 | position: absolute; 53 | left: -50px; 54 | top: -42px; 55 | 56 | display: flex; 57 | 58 | .color { 59 | width: 16px; 60 | height: 16px; 61 | 62 | border-radius: 30px; 63 | 64 | cursor: pointer; 65 | 66 | + .color { 67 | margin-left: 4px; 68 | } 69 | } 70 | `; 71 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/object-edit-menu/index.tsx: -------------------------------------------------------------------------------- 1 | import { colorChips } from '@data/workspace-object-color'; 2 | import { useState } from 'react'; 3 | import { ColorChip, ColorSelect, Container, FontSize } from './index.style'; 4 | import dropdownColor from '@assets/icon/dropdown-color.svg'; 5 | import { ObjectEditMenuProps } from './index.type'; 6 | import { ObjectType } from '../whiteboard-canvas/types'; 7 | 8 | const selectedType: { [index: string]: number } = { 9 | NONE: 0, 10 | COLOR: 1, 11 | TYPE: 2, 12 | }; 13 | 14 | function ObjectEditMenu({ selectedObject, color, fontSize, setFontSize, setObjectColor }: ObjectEditMenuProps) { 15 | const [selected, setSelected] = useState(selectedType.NONE); 16 | 17 | const handleColor = (color: string) => { 18 | setObjectColor(color); 19 | setSelected(selectedType.NONE); 20 | }; 21 | 22 | const toggleColorSelect = () => { 23 | setSelected((prevSelect) => (prevSelect === selectedType.COLOR ? selectedType.NONE : selectedType.COLOR)); 24 | }; 25 | 26 | const renderOptionBySelect = () => { 27 | if (selectedObject === ObjectType.postit) { 28 | return ( 29 | setSelected(selectedType.TYPE)} 32 | value={fontSize} 33 | onChange={setFontSize} 34 | /> 35 | ); 36 | } else return <>; 37 | }; 38 | 39 | return ( 40 | 41 | 42 |
43 | set color 44 | 45 | {renderOptionBySelect()} 46 | {selected === selectedType.COLOR && ( 47 | 48 | {colorChips.map((color) => ( 49 |
handleColor(color)} /> 50 | ))} 51 | 52 | )} 53 | 54 | ); 55 | } 56 | 57 | export default ObjectEditMenu; 58 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/object-edit-menu/index.type.ts: -------------------------------------------------------------------------------- 1 | import { ChangeEvent } from 'react'; 2 | 3 | export interface ObjectEditMenuProps { 4 | selectedObject: string; 5 | color: string; 6 | fontSize: number; 7 | setFontSize: (e: ChangeEvent) => void; 8 | setObjectColor: (color: string) => void; 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/pen-type-box/index.style.tsx: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components'; 2 | 3 | export const Container = styled.div` 4 | background: ${({ theme }) => theme.gray_1}; 5 | border-radius: 10px 10px 0 0; 6 | 7 | width: 410px; 8 | height: 46px; 9 | 10 | position: absolute; 11 | top: -46px; 12 | left: 44px; 13 | 14 | display: flex; 15 | align-items: center; 16 | `; 17 | 18 | export const Tool = styled.div<{ selected: boolean }>` 19 | width: 60px; 20 | height: 100%; 21 | 22 | cursor: pointer; 23 | 24 | border-radius: 8px 8px 0 0; 25 | 26 | .tool { 27 | width: 60px; 28 | position: absolute; 29 | 30 | ${({ selected }) => 31 | selected 32 | ? css` 33 | clip: rect(0px, 120px, 70px, 0px); 34 | top: -25px; 35 | ` 36 | : css` 37 | clip: rect(0px, 120px, 50px, 0px); 38 | top: -5px; 39 | `} 40 | 41 | :hover { 42 | top: -25px; 43 | clip: rect(0px, 120px, 70px, 0px); 44 | } 45 | } 46 | `; 47 | 48 | export const ColorChip = styled.div<{ color: string; selected: boolean }>` 49 | background: ${({ color }) => color}; 50 | border-radius: 30px; 51 | 52 | box-sizing: border-box; 53 | border: 2px solid ${({ selected, theme }) => (selected ? theme.blue_3 : 'transparent')}; 54 | 55 | width: 24px; 56 | height: 24px; 57 | 58 | cursor: pointer; 59 | 60 | & + & { 61 | margin-left: 4px; 62 | } 63 | `; 64 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/pen-type-box/index.tsx: -------------------------------------------------------------------------------- 1 | import { cursorState } from '@context/workspace'; 2 | import { toolItems } from '@data/workspace-tool'; 3 | import { isSelectedCursor } from '@utils/is-selected-cursor.utils'; 4 | import { useRecoilState } from 'recoil'; 5 | import penTool from '@assets/image/pen.png'; 6 | import eraserTool from '@assets/image/eraser.png'; 7 | import { ColorChip, Container, Tool } from './index.style'; 8 | import { colorChips } from '@data/workspace-object-color'; 9 | 10 | function PenTypeBox() { 11 | const [cursor, setCursor] = useRecoilState(cursorState); 12 | 13 | const handleCursorType = (type: number) => { 14 | setCursor({ ...cursor, type }); 15 | }; 16 | 17 | const handleCursorColor = (color: string) => { 18 | setCursor({ ...cursor, color }); 19 | }; 20 | 21 | return ( 22 | 23 | handleCursorType(toolItems.PEN)}> 24 | pen 25 | 26 | handleCursorType(toolItems.ERASER)} 29 | > 30 | eraser 31 | 32 | {isSelectedCursor(cursor.type, toolItems.PEN) && 33 | colorChips.map((color) => ( 34 | handleCursorColor(color)} 39 | > 40 | ))} 41 | 42 | ); 43 | } 44 | 45 | export default PenTypeBox; 46 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/right-side/index.style.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Container = styled.div` 4 | flex: 1; 5 | 6 | display: flex; 7 | justify-content: flex-end; 8 | align-items: center; 9 | 10 | margin-right: 20px; 11 | 12 | .share { 13 | margin-right: 20px; 14 | 15 | width: 26px; 16 | height: 26px; 17 | 18 | cursor: pointer; 19 | } 20 | `; 21 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/right-side/index.tsx: -------------------------------------------------------------------------------- 1 | import { Container } from './index.style'; 2 | import shareIcon from '@assets/icon/share.svg'; 3 | import ZoomController from '../zoom-controller'; 4 | import CurrentUserList from '../current-user-list'; 5 | 6 | interface RightSideProps { 7 | openShareModal: () => void; 8 | } 9 | 10 | function RightSide({ openShareModal }: RightSideProps) { 11 | return ( 12 | 13 | 14 | share icon 15 | 16 | 17 | ); 18 | } 19 | 20 | export default RightSide; 21 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/share-modal/index.style.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Container = styled.div` 4 | .bottom { 5 | position: absolute; 6 | bottom: 0; 7 | 8 | width: 100%; 9 | display: flex; 10 | align-items: center; 11 | 12 | border-top: 1px solid ${({ theme }) => theme.gray_1}; 13 | 14 | font-size: 15px; 15 | line-height: 20px; 16 | font-weight: 400; 17 | } 18 | 19 | .copy-link { 20 | display: flex; 21 | align-items: center; 22 | 23 | cursor: pointer; 24 | } 25 | 26 | .copy-icon { 27 | width: 36px; 28 | height: 36px; 29 | } 30 | `; 31 | 32 | export const ParticipantList = styled.div` 33 | height: 300px; 34 | overflow: auto; 35 | `; 36 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/share-modal/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { Container, ParticipantList } from './index.style'; 3 | import copyLink from '@assets/icon/copy-link.svg'; 4 | import { Workspace } from '@api/workspace'; 5 | import { ParticipantInfo, Role, RoleChangeEvent, ShareModalProps } from './index.type'; 6 | import ToastMessage from '@components/toast-message'; 7 | import MemberRole from '../member-role'; 8 | import { useRecoilState } from 'recoil'; 9 | import { workspaceParticipantsState } from '@context/workspace'; 10 | 11 | function ShareModal({ id }: ShareModalProps) { 12 | const [participants, setParticipants] = useRecoilState(workspaceParticipantsState); 13 | const [openToast, setOpenToast] = useState(false); 14 | 15 | useEffect(() => { 16 | async function getParticipant() { 17 | const result = await Workspace.getWorkspaceParticipant(id); 18 | setParticipants(result); 19 | } 20 | 21 | getParticipant(); 22 | }, []); 23 | 24 | const handleCopyLink = () => { 25 | navigator.clipboard.writeText(`${process.env.REACT_APP_CLIENT_URL}/workspace/${id}`); 26 | setOpenToast(true); 27 | 28 | const timer = setTimeout(() => { 29 | setOpenToast(false); 30 | clearTimeout(timer); 31 | }, 3000); 32 | }; 33 | 34 | const handleRole = (userId: string, role: Role) => { 35 | const roleChangeEvent = new CustomEvent('role:changed', { detail: { userId, role } }); 36 | document.dispatchEvent(roleChangeEvent); 37 | }; 38 | 39 | return ( 40 | 41 | 42 | {participants.map((part) => ( 43 | 44 | ))} 45 | 46 | 47 |
48 |
49 | copy workspace url 50 | copy link 51 |
52 |
53 | 54 | {openToast && } 55 |
56 | ); 57 | } 58 | 59 | export default ShareModal; 60 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/share-modal/index.type.ts: -------------------------------------------------------------------------------- 1 | export interface ShareModalProps { 2 | id: string; 3 | } 4 | 5 | interface UserInfo { 6 | userId: string; 7 | nickname: string; 8 | registerDate: string; 9 | } 10 | 11 | export type Role = 0 | 1 | 2; 12 | 13 | export interface ParticipantInfo { 14 | id: number; 15 | role: Role; 16 | updateDate: string; 17 | user: UserInfo; 18 | } 19 | 20 | export interface RoleChangeEvent { 21 | userId: string; 22 | role: Role; 23 | } 24 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/toolkit/index.style.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Container = styled.div` 4 | width: 472px; 5 | height: 80px; 6 | 7 | border-radius: 20px; 8 | border: 1px solid ${({ theme }) => theme.gray_1}; 9 | 10 | position: fixed; 11 | left: calc(50% - 236px); 12 | bottom: 100px; 13 | 14 | display: flex; 15 | 16 | background: ${({ theme }) => theme.white}; 17 | 18 | z-index: 4; 19 | 20 | .cursor { 21 | border-right: 1px solid ${({ theme }) => theme.gray_1}; 22 | width: 44px; 23 | } 24 | 25 | .draw-tools { 26 | width: 428px; 27 | display: flex; 28 | justify-content: space-around; 29 | } 30 | `; 31 | 32 | export const Tool = styled.div<{ selected: boolean }>` 33 | width: 90px; 34 | height: 100%; 35 | 36 | cursor: pointer; 37 | 38 | background: ${({ selected }) => selected && '#F3F3F3'}; 39 | border-radius: 8px 8px 0 0; 40 | 41 | z-index: 10; 42 | 43 | .tool { 44 | width: 90px; 45 | position: absolute; 46 | top: 10px; 47 | clip: rect(0px, 120px, 70px, 0px); 48 | 49 | :hover { 50 | top: -20px; 51 | clip: rect(0px, 120px, 100px, 0px); 52 | } 53 | } 54 | `; 55 | 56 | export const CursorBackground = styled.div<{ selected: boolean }>` 57 | background: ${({ theme, selected }) => (selected ? theme.blue_2 : theme.white)}; 58 | height: 40px; 59 | 60 | cursor: pointer; 61 | 62 | :first-child { 63 | border-radius: 20px 0 0 0; 64 | } 65 | :last-child { 66 | border-radius: 0 0 0 20px; 67 | border-top: 1px solid ${({ theme }) => theme.gray_1}; 68 | box-sizing: border-box; 69 | } 70 | `; 71 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/toolkit/index.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import { useRecoilState } from 'recoil'; 3 | import { Container, CursorBackground, Tool } from './index.style'; 4 | import { ReactComponent as SelectCursor } from '@assets/icon/toolkit-select-cursor.svg'; 5 | import { ReactComponent as MoveCursor } from '@assets/icon/toolkit-move-cursor.svg'; 6 | import penTool from '@assets/image/pen.png'; 7 | import postIt from '@assets/image/post-it.svg'; 8 | import section from '@assets/image/section.svg'; 9 | import { toolItems } from '@data/workspace-tool'; 10 | import { cursorState } from '@context/workspace'; 11 | import { isSelectedCursor } from '@utils/is-selected-cursor.utils'; 12 | import PenTypeBox from '../pen-type-box'; 13 | 14 | function Toolkit() { 15 | const [cursor, setCursor] = useRecoilState(cursorState); 16 | 17 | const handleCursor = (type: number) => { 18 | setCursor({ ...cursor, type }); 19 | }; 20 | 21 | return ( 22 | 23 |
24 | handleCursor(toolItems.SELECT)} 27 | > 28 | 29 | 30 | handleCursor(toolItems.MOVE)} 33 | > 34 | 35 | 36 |
37 |
38 | handleCursor(toolItems.PEN)} 41 | > 42 | pen 43 | 44 | handleCursor(toolItems.SECTION)} 47 | > 48 | section 49 | 50 | handleCursor(toolItems.POST_IT)} 53 | > 54 | post it 55 | 56 |
57 | {(isSelectedCursor(cursor.type, toolItems.PEN) || isSelectedCursor(cursor.type, toolItems.ERASER)) && ( 58 | 59 | )} 60 |
61 | ); 62 | } 63 | 64 | export default memo(Toolkit); 65 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/whiteboard-canvas/index.style.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const WhiteboardCanvasLayout = styled.div` 4 | position: fixed; 5 | left: 0; 6 | top: 0; 7 | z-index: 0; 8 | 9 | width: 100vw; 10 | height: 100vh; 11 | 12 | background: #f1f1f1; 13 | `; 14 | 15 | export const LoadingContainer = styled.div` 16 | position: relative; 17 | width: 100vw; 18 | height: 100vh; 19 | background: #f1f1f1; 20 | `; 21 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/whiteboard-canvas/index.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingContainer, WhiteboardCanvasLayout } from './index.style'; 2 | import useCanvas from './useCanvas'; 3 | import useSocket from './useSocket'; 4 | import ContextMenu from '@components/context-menu'; 5 | import ObjectEditMenu from '../object-edit-menu'; 6 | import { useRecoilValue } from 'recoil'; 7 | import useCanvasToSocket from './useCanvasToSocket'; 8 | import { myInfoInWorkspaceState } from '@context/user'; 9 | import { workspaceRole } from '@data/workspace-role'; 10 | import useRoleEvent from './useRoleEvent'; 11 | import ExportModal from '../export-modal'; 12 | import Loading from '@components/loading'; 13 | import ObjectWorker from 'worker/object.worker'; 14 | import CursorWorker from 'worker/cursor.worker'; 15 | import useCursorWorker from './useCursorWorker'; 16 | import useObjectWorker from './useObjectWorker'; 17 | import useOffscreencanvas from './useOffscreencanvas'; 18 | 19 | function WhiteboardCanvas() { 20 | const { canvas } = useCanvas(); 21 | useOffscreencanvas(canvas); 22 | const { socket, isEndInit } = useSocket(canvas); 23 | const { worker: cursorWorker } = useCursorWorker(CursorWorker, socket); 24 | const { worker: objectWorker } = useObjectWorker(ObjectWorker, socket); 25 | 26 | const { isOpen, menuRef, color, setObjectColor, fontSize, handleFontSize, selectedType, menuPosition } = 27 | useCanvasToSocket({ canvas, socket, cursorWorker, objectWorker }); 28 | useRoleEvent(socket); 29 | const myInfoInWorkspace = useRecoilValue(myInfoInWorkspaceState); 30 | 31 | return ( 32 | <> 33 | 34 | 35 | 36 | 37 | {myInfoInWorkspace.role !== workspaceRole.GUEST && ( 38 | 39 | 46 | 47 | )} 48 | 49 | 50 | {!isEndInit && ( 51 | 52 | 53 | 54 | )} 55 | 56 | ); 57 | } 58 | export default WhiteboardCanvas; 59 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/whiteboard-canvas/types/fabric-options.ts: -------------------------------------------------------------------------------- 1 | import { SocketObjectType } from '@pages/workspace/whiteboard-canvas/types'; 2 | export interface TitleBackgroundOptions { 3 | objectId: string; 4 | left: number; 5 | top: number; 6 | color: string; 7 | } 8 | 9 | export interface SectionOption { 10 | objectId: string; 11 | left: number; 12 | top: number; 13 | sectionTitle: fabric.IText; 14 | titleBackground: fabric.Rect; 15 | backgroundRect: fabric.Rect; 16 | selectable: boolean; 17 | } 18 | 19 | export interface SectionTitleOptions { 20 | editable: boolean; 21 | objectId: string; 22 | text?: string; 23 | left: number; 24 | top: number; 25 | groupType?: SocketObjectType; 26 | } 27 | 28 | export interface PostItOptions { 29 | objectId: string; 30 | left: number; 31 | top: number; 32 | textBox: fabric.Textbox; 33 | nameLabel: fabric.Text; 34 | backgroundRect: fabric.Rect; 35 | selectable: boolean; 36 | } 37 | 38 | export interface TextBoxOptions { 39 | objectId: string; 40 | left: number; 41 | top: number; 42 | fontSize: number; 43 | text?: string; 44 | editable: boolean; 45 | groupType?: SocketObjectType; 46 | } 47 | 48 | export interface RectOptions { 49 | objectId: string; 50 | left: number; 51 | top: number; 52 | color: string; 53 | } 54 | 55 | export interface NameLabelOptions { 56 | objectId: string; 57 | text: string; 58 | left: number; 59 | top: number; 60 | } 61 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/whiteboard-canvas/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './socket.types'; 2 | export * from './workspace-object.types'; 3 | export * from './workspace-member.types'; 4 | export * from './workspace.types'; 5 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/whiteboard-canvas/types/offscreencanvas.types.ts: -------------------------------------------------------------------------------- 1 | export interface Position { 2 | x: number; 3 | y: number; 4 | } 5 | 6 | export interface ZoomPosition extends Position { 7 | zoom: number; 8 | } 9 | 10 | export interface CanvasSize { 11 | width: number; 12 | height: number; 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/whiteboard-canvas/types/socket.types.ts: -------------------------------------------------------------------------------- 1 | import { Member, UserMousePointer, MousePointer } from './workspace-member.types'; 2 | import { ObjectDataFromServer, ObjectDataToServer } from './workspace-object.types'; 3 | import { AllWorkspaceData, Role } from './workspace.types'; 4 | 5 | export interface ServerToClientEvents { 6 | connect: () => void; 7 | disconnect: () => void; 8 | init: (arg: AllWorkspaceData) => void; 9 | enter_user: (arg: Member) => void; 10 | leave_user: (arg: { userId: string }) => void; 11 | move_pointer: (arg: UserMousePointer) => void; 12 | select_object: (arg: { userId: string; objectIds: string[] }) => void; 13 | unselect_object: (arg: { userId: string; objectIds: string[] }) => void; 14 | create_object: (arg: ObjectDataFromServer) => void; 15 | delete_object: (arg: { userId: string; objectId: string }) => void; 16 | update_object: (arg: { userId: string; objectData: ObjectDataFromServer }) => void; 17 | move_object: (arg: { userId: string; objectData: ObjectDataFromServer }) => void; 18 | scale_object: (arg: { userId: string; objectData: ObjectDataFromServer }) => void; 19 | updating_object: (arg: { userId: string; objectData: ObjectDataFromServer }) => void; 20 | change_role: (arg: { userId: string; role: Role }) => void; 21 | exception: (arg: any) => void; 22 | } 23 | 24 | export interface ClientToServerEvents { 25 | move_pointer: (arg: MousePointer) => void; 26 | select_object: (arg: { objectIds: string[] }) => void; 27 | unselect_object: (arg: { objectIds: string[] }) => void; 28 | create_object: (arg: ObjectDataToServer) => void; 29 | delete_object: (arg: { objectId: string }) => void; 30 | update_object: (arg: ObjectDataToServer) => void; 31 | move_object: (arg: ObjectDataToServer) => void; 32 | change_role: (arg: { userId: string; role: Role }) => void; 33 | scale_object: (arg: ObjectDataToServer) => void; 34 | updating_object: (arg: ObjectDataToServer) => void; 35 | } 36 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/whiteboard-canvas/types/workspace-member.types.ts: -------------------------------------------------------------------------------- 1 | export interface Member { 2 | userId: string; 3 | nickname: string; 4 | color: string; 5 | role: Role; 6 | } 7 | 8 | // 0: Viewer(단순히 보기만 함) / 1: Editor(편집 및 읽기 가능) / 2: Owner(Workspace 소유자, 워크스페이스 삭제 가능) 9 | type Role = 0 | 1 | 2; 10 | 11 | export interface MousePointer { 12 | x: number; 13 | y: number; 14 | } 15 | 16 | export interface UserMousePointer extends MousePointer { 17 | userId: string; 18 | } 19 | 20 | export interface MemberInCanvas { 21 | userId: string; 22 | color: string; 23 | cursorObject: fabric.Path; 24 | } 25 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/whiteboard-canvas/types/workspace-object.types.ts: -------------------------------------------------------------------------------- 1 | export interface CanvasObject { 2 | objectId: string; 3 | type?: ObjectType; 4 | left?: number; 5 | top?: number; 6 | width?: number; 7 | height?: number; 8 | fill?: string; 9 | text?: string; 10 | fontSize?: number; 11 | } 12 | 13 | export interface ObjectDataToServer { 14 | type?: SocketObjectType; 15 | objectId: string; 16 | left?: number; 17 | top?: number; 18 | width?: number; 19 | height?: number; 20 | color?: string; 21 | text?: string; 22 | fontSize?: number; 23 | scaleX?: number; 24 | scaleY?: number; 25 | path?: string; 26 | } 27 | 28 | export interface ObjectDataFromServer extends ObjectDataToServer { 29 | type: SocketObjectType; 30 | creator: string; 31 | workspaceId: string; 32 | } 33 | 34 | // boocrum에서 논리적으로 관리하는 object type 정의 35 | export const ObjectType = { 36 | postit: 'postit', 37 | section: 'section', 38 | draw: 'draw', 39 | text: 'text', 40 | title: 'title', 41 | nameText: 'nameText', 42 | cursor: 'cursor', 43 | rect: 'rect', 44 | line: 'line', 45 | editable: 'editable', 46 | } as const; 47 | 48 | export type ObjectType = typeof ObjectType[keyof typeof ObjectType]; 49 | 50 | // 소켓으로 보내는 object typee들 51 | export const SocketObjectType = { 52 | postit: 'postit', 53 | section: 'section', 54 | draw: 'draw', 55 | } as const; 56 | 57 | export type SocketObjectType = typeof SocketObjectType[keyof typeof SocketObjectType]; 58 | 59 | export const CanvasType = { 60 | postit: 'postit', 61 | section: 'section', 62 | move: 'move', 63 | select: 'select', 64 | draw: 'draw', 65 | edit: 'edit', 66 | erase: 'erase', 67 | } as const; 68 | 69 | export type CanvasType = typeof CanvasType[keyof typeof CanvasType]; 70 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/whiteboard-canvas/types/workspace.types.ts: -------------------------------------------------------------------------------- 1 | import { Member } from './workspace-member.types'; 2 | import { ObjectDataFromServer } from './workspace-object.types'; 3 | 4 | export interface AllWorkspaceData { 5 | members: Member[]; 6 | objects: ObjectDataFromServer[]; 7 | userData: Member; 8 | } 9 | 10 | interface UserInfo { 11 | userId: string; 12 | nickname: string; 13 | registerDate: string; 14 | } 15 | 16 | export type Role = 0 | 1 | 2; 17 | 18 | export interface ParticipantInfo { 19 | id: number; 20 | role: Role; 21 | updateDate: string; 22 | user: UserInfo; 23 | } 24 | 25 | export interface RoleInfo { 26 | userId: string; 27 | role: Role; 28 | } 29 | 30 | export interface MyInfo { 31 | userId: string; 32 | nickname: string; 33 | color: string; 34 | role: Role; 35 | } 36 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/whiteboard-canvas/useCursorWorker.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from 'react'; 2 | import { isUndefined } from '@utils/type.utils'; 3 | import { ServerToClientEvents, ClientToServerEvents } from './types'; 4 | import { Socket } from 'socket.io-client'; 5 | import { MousePointer } from '@pages/workspace/whiteboard-canvas/types'; 6 | 7 | function useCursorWorker( 8 | workerModule: () => void, 9 | socket: React.MutableRefObject | null> 10 | ) { 11 | const worker = useRef(); 12 | 13 | const initWorker = () => { 14 | const code = workerModule.toString(); 15 | const blob = new Blob([`(${code})()`]); 16 | return new Worker(URL.createObjectURL(blob)); 17 | }; 18 | 19 | useEffect(() => { 20 | worker.current = initWorker(); 21 | worker.current.onmessage = ({ 22 | data: { mouse, queueLength }, 23 | }: { 24 | data: { mouse: MousePointer; queueLength: number }; 25 | }) => { 26 | socket.current?.emit('move_pointer', mouse); 27 | }; 28 | 29 | worker.current.onerror = (error) => { 30 | console.log(error); 31 | }; 32 | 33 | return () => { 34 | if (isUndefined(worker.current)) return; 35 | worker.current.terminate(); 36 | }; 37 | }, []); 38 | 39 | return { 40 | worker, 41 | }; 42 | } 43 | 44 | export default useCursorWorker; 45 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/whiteboard-canvas/useObjectWorker.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from 'react'; 2 | import { isUndefined } from '@utils/type.utils'; 3 | import { ServerToClientEvents, ClientToServerEvents, ObjectDataToServer } from './types'; 4 | import { Socket } from 'socket.io-client'; 5 | 6 | function useObjectWorker( 7 | workerModule: () => void, 8 | socket: React.MutableRefObject | null> 9 | ) { 10 | const worker = useRef(); 11 | 12 | const initWorker = () => { 13 | const code = workerModule.toString(); 14 | const blob = new Blob([`(${code})()`]); 15 | return new Worker(URL.createObjectURL(blob)); 16 | }; 17 | 18 | useEffect(() => { 19 | worker.current = initWorker(); 20 | worker.current.onmessage = ({ data }: { data: ObjectDataToServer }) => { 21 | socket.current?.emit('updating_object', data); 22 | }; 23 | 24 | worker.current.onerror = (error) => { 25 | console.log(error); 26 | }; 27 | 28 | return () => { 29 | if (isUndefined(worker.current)) return; 30 | worker.current.terminate(); 31 | }; 32 | }, []); 33 | 34 | return { 35 | worker, 36 | }; 37 | } 38 | 39 | export default useObjectWorker; 40 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/whiteboard-canvas/useOffscreencanvas.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from 'react'; 2 | import offcanvasWorker from 'worker/offcanvas.worker'; 3 | import { Position, ZoomPosition, CanvasSize } from './types/offscreencanvas.types'; 4 | 5 | function useOffscreencanvas(canvas: React.MutableRefObject) { 6 | const worker = useRef(); 7 | 8 | const initWorker = (workerModule: () => void) => { 9 | const code = workerModule.toString(); 10 | console.log(code); 11 | const blob = new Blob([`(${code})()`]); 12 | return new Worker(URL.createObjectURL(blob)); 13 | }; 14 | 15 | useEffect(() => { 16 | const htmlCanvas = document.createElement('canvas'); 17 | htmlCanvas.width = window.innerWidth; 18 | htmlCanvas.height = window.innerHeight; 19 | htmlCanvas.style.left = '0'; 20 | htmlCanvas.style.top = '0'; 21 | 22 | htmlCanvas.style.position = 'absolute'; 23 | const offscreen = htmlCanvas.transferControlToOffscreen(); 24 | const canvasContainer = document.querySelector('.canvas-container'); 25 | canvasContainer?.insertBefore(htmlCanvas, canvasContainer.firstChild); 26 | 27 | worker.current = initWorker(offcanvasWorker); 28 | 29 | worker.current.postMessage({ type: 'init', canvas: offscreen }, [offscreen]); 30 | 31 | canvas.current?.on('canvas:move', function (e) { 32 | const position = e as unknown as Position; 33 | worker.current?.postMessage({ type: 'move', position }); 34 | }); 35 | 36 | canvas.current?.on('canvas:zoom', function (e) { 37 | const zoomPosition = e as unknown as ZoomPosition; 38 | worker.current?.postMessage({ 39 | type: 'zoom', 40 | zoom: zoomPosition.zoom, 41 | position: { 42 | x: zoomPosition.x, 43 | y: zoomPosition.y, 44 | }, 45 | }); 46 | }); 47 | 48 | canvas.current?.on('canvas:resize', function (e) { 49 | const size = e as unknown as CanvasSize; 50 | worker.current?.postMessage({ type: 'resize', size: size }); 51 | }); 52 | }, []); 53 | } 54 | 55 | export default useOffscreencanvas; 56 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/whiteboard-canvas/useRoleEvent.ts: -------------------------------------------------------------------------------- 1 | import { myInfoInWorkspaceState } from '@context/user'; 2 | import { workspaceParticipantsState } from '@context/workspace'; 3 | import { workspaceRole } from '@data/workspace-role'; 4 | import { useEffect } from 'react'; 5 | import { useRecoilState } from 'recoil'; 6 | import { Socket } from 'socket.io-client'; 7 | import { ClientToServerEvents, MyInfo, ParticipantInfo, RoleInfo, ServerToClientEvents } from './types'; 8 | 9 | function useRoleEvent(socket: React.MutableRefObject | null>) { 10 | const [participants, setParticipants] = useRecoilState(workspaceParticipantsState); 11 | const [myInfoInWorkspace, setMyInfoInWorkspace] = useRecoilState(myInfoInWorkspaceState); 12 | 13 | useEffect(() => { 14 | document.addEventListener('role:changed', messageToSocket); 15 | 16 | return () => { 17 | document.removeEventListener('role:changed', messageToSocket); 18 | }; 19 | }, [myInfoInWorkspace]); 20 | useEffect(() => { 21 | socket.current?.on('change_role', messageFromSocket); 22 | 23 | return () => { 24 | socket.current?.off('change_role', messageFromSocket); 25 | }; 26 | }, [participants]); 27 | 28 | const messageToSocket = (ev: Event) => { 29 | if (myInfoInWorkspace.role !== workspaceRole.OWNER) return; 30 | 31 | const { userId, role } = (ev as CustomEvent).detail; 32 | socket.current?.emit('change_role', { userId, role }); 33 | }; 34 | 35 | const messageFromSocket = ({ userId, role }: RoleInfo) => { 36 | const updatedParticipants = participants.map((part) => { 37 | if (part.user.userId === userId) return { ...part, role }; 38 | return part; 39 | }); 40 | setParticipants(updatedParticipants); 41 | if (userId === myInfoInWorkspace.userId) setMyInfoInWorkspace({ ...myInfoInWorkspace, role: role }); 42 | }; 43 | } 44 | 45 | export default useRoleEvent; 46 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/zoom-controller/index.style.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Container = styled.div` 4 | border: 1px solid ${({ theme }) => theme.gray_1}; 5 | border-radius: 8px; 6 | 7 | width: 140px; 8 | height: 32px; 9 | 10 | display: flex; 11 | justify-content: space-between; 12 | align-items: center; 13 | 14 | user-select: none; 15 | 16 | .zoom { 17 | width: 36px; 18 | height: 100%; 19 | display: flex; 20 | justify-content: center; 21 | align-items: center; 22 | 23 | cursor: pointer; 24 | 25 | :first-child { 26 | border-right: 1px solid ${({ theme }) => theme.gray_1}; 27 | } 28 | 29 | :last-child { 30 | border-left: 1px solid ${({ theme }) => theme.gray_1}; 31 | } 32 | } 33 | 34 | .zoom-icon { 35 | width: 12px; 36 | height: 12px; 37 | } 38 | 39 | .zoom-percent { 40 | font-size: 12px; 41 | font-weight: 500; 42 | line-height: 16px; 43 | } 44 | `; 45 | -------------------------------------------------------------------------------- /frontend/src/pages/workspace/zoom-controller/index.tsx: -------------------------------------------------------------------------------- 1 | import { Container } from './index.style'; 2 | import zoomOut from '@assets/icon/zoom-out.svg'; 3 | import zoomIn from '@assets/icon/zoom-in.svg'; 4 | import { useRecoilState } from 'recoil'; 5 | import { zoomState } from '@context/workspace'; 6 | 7 | function ZoomController() { 8 | const [zoom, setZoom] = useRecoilState(zoomState); 9 | 10 | const handleZoomOut = () => { 11 | setZoom((prevZoom) => 12 | prevZoom.percent - 10 >= 50 13 | ? { percent: prevZoom.percent - 10, event: 'control' } 14 | : { percent: prevZoom.percent, event: 'control' } 15 | ); 16 | }; 17 | 18 | const handleZoomIn = () => { 19 | setZoom((prevZoom) => 20 | prevZoom.percent + 10 <= 200 21 | ? { percent: prevZoom.percent + 10, event: 'control' } 22 | : { percent: prevZoom.percent, event: 'control' } 23 | ); 24 | }; 25 | 26 | return ( 27 | 28 |
29 | zoom out 30 |
31 |

{zoom.percent}%

32 |
33 | zoom in 34 |
35 |
36 | ); 37 | } 38 | 39 | export default ZoomController; 40 | -------------------------------------------------------------------------------- /frontend/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /frontend/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /frontend/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /frontend/src/utils/convert-time.utils.ts: -------------------------------------------------------------------------------- 1 | export function setTimestamp(time: string): string { 2 | const updateDate = new Date(time).getTime(); 3 | const currentDate = new Date().getTime(); 4 | 5 | const minuteDiff = Math.floor(Math.abs((currentDate - updateDate) / (1000 * 60))); 6 | 7 | return setDateDiff(minuteDiff); 8 | } 9 | 10 | function setDateDiff(minuteDiff: number): string { 11 | if (Math.floor(minuteDiff / 60) < 1) { 12 | return `Edited ${minuteDiff} minutes ago`; 13 | } else if (Math.floor(minuteDiff / (60 * 24)) < 1) { 14 | return `Edited ${Math.floor(minuteDiff / 60)} hours ago`; 15 | } else { 16 | return `Edited ${Math.floor(minuteDiff / (60 * 24))} days ago`; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /frontend/src/utils/is-selected-cursor.utils.ts: -------------------------------------------------------------------------------- 1 | export function isSelectedCursor(cursor: number, type: number): boolean { 2 | return cursor === type; 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/utils/member-role.utils.ts: -------------------------------------------------------------------------------- 1 | import { workspaceRole } from '@data/workspace-role'; 2 | 3 | export function convertRole(role: number): string { 4 | if (role === workspaceRole.OWNER) return 'owner'; 5 | else if (role === workspaceRole.EDITOR) return 'editor'; 6 | else return 'guest'; 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/utils/type.utils.ts: -------------------------------------------------------------------------------- 1 | export const isUndefined = (target: T | undefined): target is undefined => { 2 | return target === undefined; 3 | }; 4 | 5 | export const isNull = (target: T | null): target is null => { 6 | return target === null; 7 | }; 8 | 9 | export const isNullOrUndefined = (target: T | null | undefined): target is null | undefined => { 10 | return isNull(target) || isUndefined(target); 11 | }; 12 | -------------------------------------------------------------------------------- /frontend/src/worker/cursor.worker.ts: -------------------------------------------------------------------------------- 1 | import { MousePointer } from '@pages/workspace/whiteboard-canvas/types'; 2 | 3 | export default () => { 4 | let waitQueue: MousePointer[] = []; 5 | 6 | self.onmessage = ({ data }: { data: MousePointer }) => { 7 | waitQueue.push(data); 8 | }; 9 | 10 | setInterval(() => { 11 | const data = batchQueue(); 12 | if (data) { 13 | self.postMessage(data); 14 | } 15 | }, 33.4); 16 | 17 | const batchQueue = () => { 18 | if (waitQueue.length === 0) return; 19 | const taskQueue = waitQueue; 20 | waitQueue = []; 21 | return { 22 | mouse: taskQueue[taskQueue.length - 1], 23 | queueLength: taskQueue.length, 24 | }; 25 | }; 26 | }; 27 | -------------------------------------------------------------------------------- /frontend/src/worker/object.worker.ts: -------------------------------------------------------------------------------- 1 | import { ObjectDataToServer } from '@pages/workspace/whiteboard-canvas/types'; 2 | 3 | export default () => { 4 | let waitQueue: ObjectDataToServer[] = []; 5 | 6 | self.onmessage = ({ data }: { data: ObjectDataToServer }) => { 7 | waitQueue.push(data); 8 | }; 9 | 10 | setInterval(() => { 11 | const data = batchQueue(); 12 | if (data) { 13 | Object.keys(data).forEach((key) => { 14 | self.postMessage(data[key]); 15 | }); 16 | } 17 | }, 33.4); 18 | 19 | const batchQueue = () => { 20 | if (waitQueue.length === 0) return; 21 | const taskQueue = waitQueue; 22 | waitQueue = []; 23 | 24 | const dataByObject: { 25 | [key: string]: ObjectDataToServer; 26 | } = {}; 27 | taskQueue.forEach((item) => { 28 | if (dataByObject.hasOwnProperty(item.objectId)) { 29 | Object.assign(dataByObject[item.objectId], item); 30 | } else { 31 | dataByObject[item.objectId] = item; 32 | } 33 | }); 34 | return dataByObject; 35 | }; 36 | }; 37 | -------------------------------------------------------------------------------- /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 | "baseUrl": "./src", 23 | "paths": { 24 | "@assets/*": ["./assets/*"], 25 | "@components/*": ["./components/*"], 26 | "@context/*": ["./context/*"], 27 | "@data/*": ["./data/*"], 28 | "@hooks/*": ["./hooks/*"], 29 | "@types/*": ["./types/*"], 30 | "@utils/*": ["./utils/*"], 31 | "@pages/*": ["./pages/*"], 32 | "@api/*": ["./api/*"] 33 | } 34 | }, 35 | "include": [ 36 | "src" 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web22-BooCrum", 3 | "lockfileVersion": 2, 4 | "requires": true, 5 | "packages": {} 6 | } 7 | -------------------------------------------------------------------------------- /util/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web22-BooCrum/99ea3a697c4db9a38a0e049a0b59df9c395c1b79/util/.DS_Store -------------------------------------------------------------------------------- /util/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /util/.gitmessage.txt: -------------------------------------------------------------------------------- 1 | # <타입>: - <제목> <#1> 2 | 3 | ##### 제목은 최대 50 글자까지만 입력 ############## -> | 4 | 5 | 6 | # 본문은 위에 작성 7 | ######## 본문은 한 줄에 최대 72 글자까지만 입력 ########################### -> | 8 | # --- COMMIT END --- 9 | # <타입> 리스트 10 | # feat : 기능 (새로운 기능) 11 | # fix : 버그 (버그 수정) 12 | # refactor: 리팩토링 13 | # style : 스타일 (코드 형식, 세미콜론 추가: 비즈니스 로직에 변경 없음) 14 | # docs : 문서 (문서 추가, 수정, 삭제) 15 | # test : 테스트 (테스트 코드 추가, 수정, 삭제: 비즈니스 로직에 변경 없음) 16 | # chore : 기타 변경사항 (빌드 스크립트 수정, 패키지 설치 등) 17 | # ------------------ 18 | # 타입은 영어로 작성하고 제목과 본문은 한글로 작성한다. 19 | # 제목 끝에 마침표(.) 금지 20 | # 제목과 본문을 한 줄 띄워 분리하기 21 | # 본문은 "어떻게" 보다 "무엇을", "왜"를 설명한다. 22 | # 본문에 여러줄의 메시지를 작성할 땐 "-"로 구분 23 | # 관련된 이슈번호는 제목 맨 뒤에 추가한다. ex. #1 24 | # ------------------ 25 | -------------------------------------------------------------------------------- /util/.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | cd util 5 | npx commitlint --config commitlint.config.js --edit $1 6 | -------------------------------------------------------------------------------- /util/commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | plugins: ['commitlint-plugin-function-rules'], 4 | rules: { 5 | // 스코프는 컨벤션과 맞지 않기에, 사용하지 않는 것으로 한다. 6 | 'scope-empty': [2,'always'], 7 | // 헤더의 길이는 50자로 제한한다. 8 | 'header-max-length': [2, 'always', 50], 9 | // 본문의 한 줄은 72자로 제한한다. 10 | 'body-max-line-length': [2, 'always', 72], 11 | // 타입은 아래의 태그만 사용하도록 한다. 12 | 'type-enum': [2, 'always', ['feat','fix','refactor','style','docs','test','chore']], 13 | // 이슈 번호로 종료되는지, .으로 끝나지 않는지 확인한다. 14 | // fe, be로 시작하는지 확인 15 | // 제목이 한글인지 확인 16 | 'subject-full-stop': [0], 17 | 'function-rules/subject-full-stop': [ 18 | 2, 19 | 'always', 20 | ({ subject, header, body, raw })=>{ 21 | 22 | // 1. 이슈 번호로 종료되는가? 23 | if (subject && !/[^\.] #[0-9]+$/.test(subject)) 24 | return [false, 'subject는 issue 번호를 #[number] 형식으로 끝에 포함해야하며, .(점) 으로 끝나지 말아야 합니다.']; 25 | 26 | // 2. FE / BE 로 시작하는가? 27 | if (subject && !/^(fe|be|all) ?- ?/.test(subject.trim())) 28 | return [false, 'subject는 "FE - " 혹은 "BE - "로 시작해야 합니다.']; 29 | 30 | // 3. 제목이 한글로 작성되었는가? 31 | // if (!/[a-z]+/i.test(subject.trim())) 32 | // return [false, '제목(subject)은 한글로 작성해주십시오. 부득이한 경우 담당자에게 문의 바랍니다.']; 33 | 34 | // 4. 제목과 바디가 공백으로 분리되어있는가? 35 | if (body && raw && header && raw.search('\n\n') !== header.length) 36 | return [false, '제목과 본문은 공백으로 구분해주시기 바랍니다.']; 37 | 38 | return [true]; 39 | }], 40 | 'body-leading-blank': [0], 41 | 'function-rules/body-leading-blank': [ 42 | 2, 43 | 'always', 44 | ({ body })=>{ 45 | // body가 없으면 굳이 판단하지 않는다. 46 | if (!body) 47 | return [true]; 48 | if (body.split('\n').filter(line=>!/ *-/.test(line)).length > 0) 49 | return [false, '본문의 각 줄은 - 으로 시작해야 합니다.']; 50 | return [true]; 51 | } 52 | ], 53 | }, 54 | } -------------------------------------------------------------------------------- /util/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "@commitlint/cli": "^17.2.0", 4 | "@commitlint/config-conventional": "^17.2.0", 5 | "@commitlint/types": "^17.0.0", 6 | "commitlint-plugin-function-rules": "^1.7.1", 7 | "husky": "^8.0.2" 8 | }, 9 | "scripts": { 10 | "prepare": "cd .. && husky install util/.husky" 11 | } 12 | } 13 | --------------------------------------------------------------------------------