├── .github ├── ISSUE_TEMPLATE │ ├── docs.md │ ├── feat.md │ ├── fix.md │ ├── perf.md │ ├── refactor.md │ ├── setting.md │ └── test.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── cd.yml │ └── ci.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierrc ├── @wabinar ├── api-types │ ├── auth.ts │ ├── block.ts │ ├── mom.ts │ ├── package.json │ ├── user.ts │ └── workspace.ts ├── constants │ ├── block.ts │ ├── package.json │ └── socket-message.ts └── crdt │ ├── index.ts │ ├── jest.config.js │ ├── linked-list.ts │ ├── node.ts │ ├── package.json │ ├── test │ ├── convergence.test.ts │ ├── crdt.test.ts │ ├── exception.test.ts │ └── utils.ts │ └── tsconfig.json ├── README.md ├── client ├── .env.vault ├── .eslintrc ├── .gitignore ├── .stylelintrc ├── index.html ├── jest.config.js ├── package.json ├── public │ ├── favicon.svg │ └── og-img.png ├── src │ ├── App.tsx │ ├── apis │ │ ├── auth.ts │ │ ├── http-status.ts │ │ ├── http.ts │ │ ├── user.ts │ │ └── workspace.ts │ ├── components │ │ ├── Block │ │ │ ├── QuestionBlock │ │ │ │ ├── index.tsx │ │ │ │ └── style.module.scss │ │ │ ├── TextBlock.tsx │ │ │ ├── VoteBlock │ │ │ │ ├── VoteBlockTemplate.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── style.module.scss │ │ │ ├── index.tsx │ │ │ └── style.module.scss │ │ ├── BlockSelector │ │ │ ├── BlockItem │ │ │ │ ├── index.tsx │ │ │ │ └── style.module.scss │ │ │ ├── index.tsx │ │ │ └── style.module.scss │ │ ├── MeetingMediaBar │ │ │ ├── MeetingMedia │ │ │ │ ├── index.tsx │ │ │ │ └── style.module.scss │ │ │ ├── StreamButton │ │ │ │ ├── CamButton │ │ │ │ │ └── index.tsx │ │ │ │ ├── MicButton │ │ │ │ │ └── index.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── style.module.scss │ │ │ ├── index.tsx │ │ │ └── style.module.scss │ │ ├── MetaHelmet │ │ │ └── index.tsx │ │ ├── Mom │ │ │ ├── DefaultMom.tsx │ │ │ ├── EventEmitter.tsx │ │ │ ├── Skeleton.tsx │ │ │ ├── index.tsx │ │ │ └── style.module.scss │ │ ├── Sidebar │ │ │ ├── Header.tsx │ │ │ ├── MeetingButton.tsx │ │ │ ├── MemberList.tsx │ │ │ ├── MomList.tsx │ │ │ ├── SettingIcon.tsx │ │ │ ├── Skeleton.tsx │ │ │ ├── index.tsx │ │ │ └── style.module.scss │ │ ├── Workspace │ │ │ ├── DefaultWorkspace.tsx │ │ │ ├── Skeleton │ │ │ │ ├── index.tsx │ │ │ │ └── style.module.scss │ │ │ ├── index.tsx │ │ │ └── style.module.scss │ │ ├── WorkspaceList │ │ │ ├── AddButton.tsx │ │ │ ├── Skeleton.tsx │ │ │ ├── index.tsx │ │ │ └── style.module.scss │ │ ├── WorkspaceModal │ │ │ ├── CreateModal.tsx │ │ │ ├── CreateSuccessModal.tsx │ │ │ ├── FormModal.tsx │ │ │ ├── JoinModal.tsx │ │ │ ├── index.tsx │ │ │ └── style.module.scss │ │ ├── WorkspaceSettingModal │ │ │ ├── index.tsx │ │ │ └── style.module.scss │ │ ├── WorkspaceThumbnailList │ │ │ ├── WorkspaceThumbnailItem.tsx │ │ │ ├── index.tsx │ │ │ └── style.module.scss │ │ └── common │ │ │ ├── Button │ │ │ ├── index.tsx │ │ │ └── style.module.scss │ │ │ ├── CopyButton │ │ │ └── index.tsx │ │ │ ├── GuideIcon │ │ │ ├── GuideMessage.tsx │ │ │ ├── index.tsx │ │ │ └── style.module.scss │ │ │ ├── Icon │ │ │ ├── Bubbles │ │ │ │ └── index.tsx │ │ │ ├── Github │ │ │ │ └── index.tsx │ │ │ ├── Logo │ │ │ │ └── index.tsx │ │ │ ├── Wab │ │ │ │ ├── AmazedWabIcon.tsx │ │ │ │ ├── SleepingWabIcon.tsx │ │ │ │ └── index.tsx │ │ │ └── Wabinar │ │ │ │ └── index.tsx │ │ │ ├── Loader │ │ │ └── index.tsx │ │ │ ├── Modal │ │ │ ├── Portal │ │ │ │ └── index.tsx │ │ │ ├── index.tsx │ │ │ └── style.module.scss │ │ │ ├── Selector │ │ │ ├── Dropdown │ │ │ │ ├── Item.tsx │ │ │ │ ├── Menu.tsx │ │ │ │ ├── Trigger.tsx │ │ │ │ └── index.tsx │ │ │ └── index.tsx │ │ │ └── Toaster │ │ │ └── index.tsx │ ├── config │ │ └── index.ts │ ├── constants │ │ ├── block.ts │ │ ├── error-message.ts │ │ ├── meta.ts │ │ ├── rtc.ts │ │ └── workspace.ts │ ├── contexts │ │ ├── dropdown.ts │ │ ├── meeting.ts │ │ ├── rtc.ts │ │ ├── selected-mom.ts │ │ ├── socket.ts │ │ ├── user.ts │ │ └── workspaces.ts │ ├── hooks │ │ ├── context │ │ │ ├── useDropdownContext.ts │ │ │ ├── useMeetingContext.ts │ │ │ ├── useMyMediaStreamContext.ts │ │ │ ├── useSelectedMomContext.ts │ │ │ ├── useSocketContext.ts │ │ │ ├── useUserContext.ts │ │ │ ├── useUserStreamsContext.ts │ │ │ └── useWorkspacesContext.ts │ │ ├── useCRDT.ts │ │ ├── useCreateMediaStream.ts │ │ ├── useDebounce.ts │ │ ├── useDebounceInput.ts │ │ ├── useJoinMeeting.ts │ │ ├── useMeetingMediaStreams.ts │ │ ├── useOffset.ts │ │ ├── usePeerConnection.ts │ │ └── useSocket.ts │ ├── lib │ │ └── debounce.ts │ ├── main.tsx │ ├── pages │ │ ├── 404.tsx │ │ ├── Loading │ │ │ ├── index.tsx │ │ │ └── style.module.scss │ │ ├── Login │ │ │ ├── index.tsx │ │ │ └── style.module.scss │ │ ├── OAuth │ │ │ ├── index.tsx │ │ │ └── style.module.scss │ │ └── Workspace │ │ │ ├── Layout.tsx │ │ │ ├── index.tsx │ │ │ └── style.module.scss │ ├── styles │ │ ├── color.module.scss │ │ └── reset.scss │ ├── types │ │ ├── block.d.ts │ │ ├── mom.d.ts │ │ ├── selector.d.ts │ │ ├── user.d.ts │ │ └── workspace.d.ts │ ├── utils │ │ ├── rtc │ │ │ └── index.ts │ │ └── trackSetter │ │ │ ├── index.test.ts │ │ │ └── index.ts │ └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── deploy-scripts ├── build-frontend-and-backend.sh ├── config.sh ├── pull-production-dotenv.sh └── try-dotenv-vault-login.sh ├── deploy.sh ├── package-lock.json ├── package.json └── server ├── .env.vault ├── .eslintrc ├── .gitignore ├── apis ├── auth │ ├── controller.ts │ ├── service.github.ts │ ├── service.test.ts │ └── service.ts ├── mom │ ├── block │ │ ├── model.ts │ │ ├── question │ │ │ └── service.ts │ │ ├── service.ts │ │ └── vote │ │ │ └── service.ts │ ├── model.ts │ └── service.ts ├── user │ ├── controller.ts │ ├── model.ts │ ├── service.test.ts │ └── service.ts └── workspace │ ├── controller.ts │ ├── model.ts │ ├── service.test.ts │ └── service.ts ├── config └── index.ts ├── constants ├── error-message.ts └── http-status.ts ├── db └── index.ts ├── debug └── websocket-analyzer.ts ├── errors ├── authorization-error.ts ├── forbidden-error.ts ├── index.ts ├── invalid-join-error.ts └── invalid-workspace-error.ts ├── index.ts ├── jest.config.js ├── middlewares ├── cors.ts ├── error-handler.ts ├── express.d.ts └── jwt-authenticator.ts ├── package.json ├── socket ├── index.ts ├── mom │ ├── handleQuestionBlock.ts │ ├── handleTextBlock.ts │ ├── handleVoteBlock.ts │ └── index.ts └── workspace.ts ├── tsconfig.json ├── tsconfig.path.json ├── utils ├── async-wrapper.ts ├── crdt-manager.ts └── jwt.ts └── vite.config.ts /.github/ISSUE_TEMPLATE/docs.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Docs 3 | about: 프로젝트 문서 작업 이슈 4 | title: "Docs" 5 | labels: "📝 Docs" 6 | --- 7 | 8 | ## 📝 문서 작업 사항 9 | 10 | 11 | 12 | - [ ] docs-1 13 | 14 | ## 📖 참고 사항 15 | 16 | 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feat.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feat 3 | about: 프로젝트 개발 기능 이슈 4 | title: "Feat" 5 | labels: "✨ Feat" 6 | --- 7 | 8 | ## 💎 개발할 기능 9 | 10 | 11 | 12 | - [ ] feat-1 13 | 14 | ## 📖 참고 사항 15 | 16 | 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/fix.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Fix 3 | about: 프로젝트 버그 수정 이슈 4 | title: "Fix" 5 | labels: "🐞 Fix" 6 | --- 7 | 8 | ## 👨‍🔧 버그 수정 사항 9 | 10 | 11 | 12 | - [ ] fix-1 13 | 14 | ## 📖 참고 사항 15 | 16 | 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/perf.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Perf 3 | about: 프로젝트 성능 개선 이슈 4 | title: "Perf" 5 | labels: "📈 Perf" 6 | --- 7 | 8 | ## 🏅 성능 개선 사항 9 | 10 | 11 | 12 | - [ ] perf-1 13 | 14 | ## 📖 참고 사항 15 | 16 | 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/refactor.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Refactor 3 | about: 프로젝트 리팩토링 이슈 4 | title: "Refactor" 5 | labels: "♻️ Refactor" 6 | --- 7 | 8 | ## ♻️ 리팩토링 사항 9 | 10 | 11 | 12 | - [ ] refactor-1 13 | 14 | ## 📖 참고 사항 15 | 16 | 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/setting.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Setting 3 | about: 프로젝트 환경세팅 이슈 4 | title: "Setting" 5 | labels: "🛠️ Setting" 6 | --- 7 | 8 | ## 🛠️ 작업사항 9 | 10 | 11 | 12 | - [ ] setting-1 13 | 14 | ## 📖 참고 사항 15 | 16 | 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/test.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Test 3 | about: 테스팅 관련 이슈 4 | title: "Test" 5 | labels: "🔍 Test" 6 | --- 7 | 8 | ## 🤖 테스트 사항 9 | 10 | 11 | 12 | - [ ] test-1 13 | 14 | ## 📖 참고 사항 15 | 16 | 17 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## 🤠 개요 2 | 3 | 9 | 10 | ## 💫 설명 11 | 12 | 17 | 18 | ## 🌜 고민거리 (Optional) 19 | 20 | 26 | 27 | ## 📷 스크린샷 (Optional) -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: cd 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [ main ] 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Deploy 14 | uses: appleboy/ssh-action@v0.1.4 15 | with: 16 | host: ${{ secrets.SSH_HOST }} 17 | username: ${{ secrets.SSH_USERNAME }} 18 | password: ${{ secrets.SSH_PASSWORD }} 19 | port: ${{ secrets.SSH_PORT }} 20 | script: ${{ secrets.DEPLOY_SCRIPT }} 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | branches: [dev] 7 | 8 | jobs: 9 | build: 10 | name: Build and test 11 | strategy: 12 | matrix: 13 | os: [ubuntu-latest, windows-latest, macos-latest] 14 | node-version: [16, 18] 15 | 16 | runs-on: ${{ matrix.os }} 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | 21 | - name: Use node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | 26 | - name: Cache dependencies 27 | id: cache 28 | uses: actions/cache@v3 29 | if: runner.os != 'Windows' # 윈도우는 이슈가 있어 캐싱 건너 뜀 (#325 참고) 30 | with: 31 | path: '**/node_modules' 32 | key: ${{ matrix.os }}-${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }} 33 | 34 | - name: Install Dependencies 35 | if: steps.cache.outputs.cache-hit != 'true' 36 | run: npm ci 37 | 38 | - name: For CRDT package directory 39 | shell: bash 40 | run: | 41 | cd @wabinar/crdt 42 | npm test 43 | 44 | - name: For client directory 45 | run: | 46 | cd client 47 | npm run build --if-present 48 | npm test --if-present 49 | 50 | - name: Install coreutils for macOS 51 | shell: bash 52 | if: runner.os == 'macOS' 53 | run: | 54 | brew install coreutils 55 | alias timeout=gtimeout 56 | 57 | - name: For server directory 58 | shell: bash 59 | env: 60 | BACKEND_LOGIN_KEY: ${{ secrets.DOTENV_VAULT_BACKEND_CI_LOGIN_KEY }} 61 | run: | 62 | cd server 63 | npm run build --if-present 64 | npm test --if-present 65 | npx dotenv-vault login "$BACKEND_LOGIN_KEY" > /dev/null 66 | npx dotenv-vault pull ci .env 67 | timeout --verbose 10 npx ts-node index.ts || { if [ $? -eq 124 ]; then (exit 0); else (exit $?); fi } 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ignore secret keys 2 | deploy-scripts/vault-secrets 3 | 4 | # ignore jest test coverage directories 5 | **/coverage 6 | 7 | # ignore settings for yarn berry zero install 8 | .yarn/* 9 | !.yarn/cache 10 | !.yarn/releases 11 | !.yarn/plugins 12 | !.yarn/sdks 13 | !.yarn/versions 14 | 15 | # Logs 16 | logs 17 | *.log 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | pnpm-debug.log* 22 | lerna-debug.log* 23 | 24 | node_modules 25 | dist 26 | dist-ssr 27 | *.local 28 | 29 | # Editor directories and files 30 | .idea 31 | .DS_Store 32 | *.suo 33 | *.ntvs* 34 | *.njsproj 35 | *.sln 36 | *.sw? 37 | 38 | .env 39 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm run lint-staged 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "all", 4 | "useTabs": false, 5 | "tabWidth": 2, 6 | "bracketSpacing": true, 7 | "singleQuote": true, 8 | "endOfLine": "lf" 9 | } 10 | -------------------------------------------------------------------------------- /@wabinar/api-types/auth.ts: -------------------------------------------------------------------------------- 1 | import { User } from './user'; 2 | 3 | export interface PostLoginBody { 4 | code: string; 5 | } 6 | 7 | export interface LoginResBody { 8 | user: User; 9 | } 10 | -------------------------------------------------------------------------------- /@wabinar/api-types/block.ts: -------------------------------------------------------------------------------- 1 | import { BlockType } from '@wabinar/constants/block'; 2 | import LinkedList, { 3 | RemoteDeleteOperation, 4 | RemoteInsertOperation, 5 | } from '@wabinar/crdt/linked-list'; 6 | 7 | export interface LoadType { 8 | id: string; 9 | } 10 | 11 | export interface LoadedType { 12 | type: BlockType; 13 | } 14 | 15 | export interface UpdateType { 16 | id: string; 17 | type: BlockType; 18 | } 19 | 20 | export interface UpdatedType { 21 | id: string; 22 | type: BlockType; 23 | } 24 | 25 | export interface InitText { 26 | id: string; 27 | } 28 | 29 | export interface InitializedText { 30 | id: string; 31 | crdt: LinkedList; 32 | } 33 | 34 | export interface InsertText { 35 | id: string; 36 | op: RemoteInsertOperation; 37 | } 38 | 39 | export interface InsertedText { 40 | id: string; 41 | op: RemoteInsertOperation; 42 | } 43 | 44 | export interface DeleteText { 45 | id: string; 46 | op: RemoteDeleteOperation; 47 | } 48 | 49 | export interface DeletedText { 50 | id: string; 51 | op: RemoteDeleteOperation; 52 | } 53 | 54 | export interface UpdateText { 55 | id: string; 56 | ops: RemoteInsertOperation[]; 57 | } 58 | 59 | export interface UpdatedText { 60 | id: string; 61 | crdt: LinkedList; 62 | } 63 | -------------------------------------------------------------------------------- /@wabinar/api-types/mom.ts: -------------------------------------------------------------------------------- 1 | import LinkedList, { 2 | RemoteDeleteOperation, 3 | RemoteInsertOperation, 4 | } from '@wabinar/crdt/linked-list'; 5 | 6 | export type Mom = { 7 | _id: string; 8 | title: string; 9 | createdAt: Date; 10 | }; 11 | 12 | export interface Created { 13 | mom: Mom; 14 | } 15 | 16 | export interface Select { 17 | id: string; 18 | } 19 | 20 | export interface Selected { 21 | mom: Mom; 22 | } 23 | 24 | export interface UpdateTitle { 25 | title: string; 26 | } 27 | 28 | export interface UpdatedTitle { 29 | title: string; 30 | } 31 | 32 | export interface Initialized { 33 | crdt: LinkedList; 34 | } 35 | 36 | export interface InsertBlock { 37 | blockId: string; 38 | op: RemoteInsertOperation; 39 | } 40 | 41 | export interface InsertedBlock { 42 | op: RemoteInsertOperation; 43 | } 44 | 45 | export interface DeleteBlock { 46 | blockId: string; 47 | op: RemoteDeleteOperation; 48 | } 49 | 50 | export interface DeletedBlock { 51 | op: RemoteDeleteOperation; 52 | } 53 | -------------------------------------------------------------------------------- /@wabinar/api-types/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@wabinar/api-types", 3 | "version": "1.0.0", 4 | "description": "API types for wabinar", 5 | "license": "MIT" 6 | } 7 | -------------------------------------------------------------------------------- /@wabinar/api-types/user.ts: -------------------------------------------------------------------------------- 1 | import { Workspace } from './workspace'; 2 | 3 | export type User = { 4 | id: number; 5 | name: string; 6 | avatarUrl: string; 7 | }; 8 | 9 | export interface GetWorkspaceParams { 10 | id: number; 11 | } 12 | 13 | export interface GetWorkspacesResBody { 14 | workspaces: Workspace[]; 15 | } 16 | -------------------------------------------------------------------------------- /@wabinar/api-types/workspace.ts: -------------------------------------------------------------------------------- 1 | export interface Workspace { 2 | id: number; 3 | name: string; 4 | code: string; 5 | } 6 | 7 | export interface PostBody { 8 | name: string; 9 | } 10 | 11 | export interface PostJoinBody { 12 | code: string; 13 | } 14 | 15 | export interface GetInfoParams { 16 | id: string; 17 | } 18 | -------------------------------------------------------------------------------- /@wabinar/constants/block.ts: -------------------------------------------------------------------------------- 1 | export enum BlockType { 2 | H1, 3 | H2, 4 | H3, 5 | P, 6 | VOTE, 7 | QUESTION, 8 | } 9 | -------------------------------------------------------------------------------- /@wabinar/constants/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@wabinar/constants", 3 | "version": "1.0.0", 4 | "description": "Constants for wabinar", 5 | "license": "MIT" 6 | } 7 | -------------------------------------------------------------------------------- /@wabinar/constants/socket-message.ts: -------------------------------------------------------------------------------- 1 | export const WORKSPACE_EVENT = { 2 | START_MEETING: 'start-meeting', 3 | END_MEETING: 'end-meeting', 4 | SEND_HELLO: 'send-hello', 5 | RECEIVE_HELLO: 'receive-hello', 6 | SEND_OFFER: 'send-offer', 7 | RECEIVE_OFFER: 'receive-offer', 8 | SEND_ANSWER: 'send-answer', 9 | RECEIVE_ANSWER: 'receive-answer', 10 | SEND_ICE: 'send-ice', 11 | RECEIVE_ICE: 'receive-ice', 12 | AUDIO_STATE_CHANGED: 'audio-state-changed', 13 | VIDEO_STATE_CHANGED: 'video-state-changed', 14 | SEND_BYE: 'send-bye', 15 | RECEIVE_BYE: 'receive-bye', 16 | EXISTING_ROOM_USERS: 'existing-room-users', 17 | }; 18 | 19 | export const MOM_EVENT = { 20 | CREATE: 'create', 21 | SELECT: 'select', 22 | UPDATE_TITLE: 'update-mom-title', 23 | INIT: 'init-mom', 24 | INSERT_BLOCK: 'insert-block', 25 | DELETE_BLOCK: 'delete-block', 26 | UPDATED: 'updated-mom', 27 | LOADED: 'loaded-mom', 28 | REQUEST_LOADED: 'request-loaded', 29 | }; 30 | 31 | export const BLOCK_EVENT = { 32 | LOAD_TYPE: 'load-type', 33 | UPDATE_TYPE: 'update-type', 34 | INIT_TEXT: 'init-text', 35 | INSERT_TEXT: 'insert-text', 36 | DELETE_TEXT: 'delete-text', 37 | UPDATE_TEXT: 'update-text', 38 | REGISTER_VOTE: 'register-vote', 39 | UPDATE_VOTE: 'update-vote', 40 | END_VOTE: 'end-vote', 41 | FETCH_QUESTIONS: 'fetch-questions', 42 | ADD_QUESTIONS: 'add-questions', 43 | RESOLVE_QUESTIONS: 'resolve-questions', 44 | }; 45 | -------------------------------------------------------------------------------- /@wabinar/crdt/index.ts: -------------------------------------------------------------------------------- 1 | import LinkedList, { 2 | RemoteDeleteOperation, 3 | RemoteInsertOperation, 4 | } from './linked-list'; 5 | import { Identifier } from './node'; 6 | 7 | class CRDT { 8 | private clock: number; 9 | private client: number; 10 | private structure: LinkedList; 11 | 12 | constructor(client: number = 0, initialStructure: LinkedList) { 13 | this.client = client; 14 | 15 | this.structure = new LinkedList(initialStructure); 16 | 17 | const { nodeMap } = initialStructure; 18 | 19 | if (!nodeMap || !Object.keys(nodeMap).length) { 20 | this.clock = 1; 21 | return this; 22 | } 23 | 24 | // logical clock 동기화를 위함 25 | const maxClock = Object.keys(nodeMap) 26 | .map((id) => Number(JSON.parse(id).clock)) 27 | .reduce((prev, cur) => Math.max(prev, cur), 0); 28 | 29 | this.clock = maxClock + 1; 30 | } 31 | 32 | get timestamp() { 33 | return this.clock; 34 | } 35 | 36 | get data() { 37 | return this.structure; 38 | } 39 | 40 | get plainData() { 41 | // DB에 저장할 때 ref 제거하기 위함 42 | const stringifiedData = JSON.stringify(this.structure); 43 | 44 | return JSON.parse(stringifiedData); 45 | } 46 | 47 | localInsert(index: number, value: string): RemoteInsertOperation { 48 | const id = new Identifier(this.clock++, this.client); 49 | const remoteInsertion = this.structure.insertByIndex(index, value, id); 50 | 51 | return remoteInsertion; 52 | } 53 | 54 | localDelete(index: number): RemoteDeleteOperation { 55 | const targetId = this.structure.deleteByIndex(index); 56 | 57 | return { targetId, clock: this.clock }; 58 | } 59 | 60 | remoteInsert({ node }: RemoteInsertOperation) { 61 | const prevIndex = this.structure.insertById(node); 62 | 63 | if (++this.clock < node.id.clock) { 64 | this.clock = node.id.clock + 1; 65 | } 66 | 67 | return prevIndex; 68 | } 69 | 70 | remoteDelete({ targetId, clock }: RemoteDeleteOperation) { 71 | const targetIndex = this.structure.deleteById(targetId); 72 | 73 | if (++this.clock < clock) { 74 | this.clock = clock + 1; 75 | } 76 | 77 | return targetIndex; 78 | } 79 | 80 | read() { 81 | return this.structure.stringify(); 82 | } 83 | 84 | spread() { 85 | return this.structure.spread(); 86 | } 87 | } 88 | 89 | export default CRDT; 90 | -------------------------------------------------------------------------------- /@wabinar/crdt/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', // to use typescript 3 | verbose: true, 4 | collectCoverage: true, 5 | }; 6 | -------------------------------------------------------------------------------- /@wabinar/crdt/node.ts: -------------------------------------------------------------------------------- 1 | export class Identifier { 2 | clock: number; 3 | client: number; 4 | 5 | constructor(clock: number, client: number) { 6 | this.clock = clock; 7 | this.client = client; 8 | } 9 | } 10 | 11 | export class Node { 12 | id: Identifier; 13 | value: string; 14 | next: Identifier | null; 15 | prev: Identifier | null; 16 | 17 | constructor(value: string, id: Identifier) { 18 | this.id = id; 19 | this.value = value; 20 | this.next = null; 21 | this.prev = null; 22 | } 23 | 24 | precedes(node: Node) { 25 | // prev가 다른 경우는 비교 대상에서 제외 26 | if (JSON.stringify(this.prev) !== JSON.stringify(node.prev)) return false; 27 | 28 | if (node.id.clock < this.id.clock) return true; 29 | 30 | if (this.id.clock === node.id.clock && this.id.client < node.id.client) 31 | return true; 32 | 33 | return false; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /@wabinar/crdt/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@wabinar/crdt", 3 | "version": "1.0.0", 4 | "description": "CRDT for wabinar", 5 | "license": "MIT", 6 | "scripts": { 7 | "test": "jest" 8 | }, 9 | "devDependencies": { 10 | "jest": "^29.3.1" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /@wabinar/crdt/test/convergence.test.ts: -------------------------------------------------------------------------------- 1 | import CRDT from '../index'; 2 | import LinkedList from '../linked-list'; 3 | import { convergenceCheck, remoteInsertThroughSocket } from './utils'; 4 | 5 | let 원희, 주영, 도훈, 세영, remoteSites; 6 | 7 | const arrange = () => { 8 | const 원희remotes = [원희.localInsert(-1, '녕'), 원희.localInsert(-1, '안')]; 9 | 10 | [주영, 도훈, 세영].forEach((나) => { 11 | 원희remotes.forEach((op) => remoteInsertThroughSocket(나, op)); 12 | }); 13 | }; 14 | 15 | describe('Convergence', () => { 16 | beforeEach(() => { 17 | 원희 = new CRDT(1, new LinkedList()); 18 | 주영 = new CRDT(2, new LinkedList()); 19 | 도훈 = new CRDT(3, new LinkedList()); 20 | 세영 = new CRDT(4, new LinkedList()); 21 | 22 | remoteSites = [원희, 주영, 도훈, 세영]; 23 | 24 | arrange(); 25 | }); 26 | 27 | it('하나의 site에서 삽입', () => { 28 | arrange(); 29 | 30 | convergenceCheck(remoteSites); 31 | }); 32 | 33 | it('여러 site에서 같은 위치에 삽입', () => { 34 | const 도훈remote = 도훈.localInsert(0, '왭'); 35 | const 세영remote = 세영.localInsert(0, '?'); 36 | 37 | remoteInsertThroughSocket(도훈, 세영remote); 38 | remoteInsertThroughSocket(세영, 도훈remote); 39 | 40 | [원희, 주영].forEach((나) => { 41 | remoteInsertThroughSocket(나, 도훈remote); 42 | remoteInsertThroughSocket(나, 세영remote); 43 | }); 44 | 45 | convergenceCheck(remoteSites); 46 | }); 47 | 48 | it('여러 site에서 다른 위치에 삽입', () => { 49 | const 원희remote = 원희.localInsert(0, '네'); 50 | const 주영remote = 주영.localInsert(1, '!'); 51 | 52 | remoteInsertThroughSocket(원희, 주영remote); 53 | remoteInsertThroughSocket(주영, 원희remote); 54 | 55 | [도훈, 세영].forEach((나) => { 56 | remoteInsertThroughSocket(나, 원희remote); 57 | remoteInsertThroughSocket(나, 주영remote); 58 | }); 59 | 60 | convergenceCheck(remoteSites); 61 | }); 62 | 63 | it('여러 site에서 같은 위치 삭제', () => { 64 | const 도훈remote = 도훈.localDelete(0); 65 | const 세영remote = 세영.localDelete(0); 66 | 67 | 도훈.remoteDelete(세영remote); 68 | 세영.remoteDelete(도훈remote); 69 | 70 | [원희, 주영].forEach((나) => { 71 | 나.remoteDelete(세영remote); 72 | 나.remoteDelete(도훈remote); 73 | }); 74 | 75 | convergenceCheck(remoteSites); 76 | }); 77 | 78 | it('여러 site에서 다른 위치 삭제', () => { 79 | const 원희remote = 원희.localDelete(0); 80 | const 주영remote = 주영.localDelete(1); 81 | 82 | 원희.remoteDelete(주영remote); 83 | 주영.remoteDelete(원희remote); 84 | 85 | [도훈, 세영].forEach((나) => { 86 | 나.remoteDelete(원희remote); 87 | 나.remoteDelete(주영remote); 88 | }); 89 | 90 | convergenceCheck(remoteSites); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /@wabinar/crdt/test/crdt.test.ts: -------------------------------------------------------------------------------- 1 | import CRDT from '../index'; 2 | import LinkedList from '../linked-list'; 3 | 4 | describe('CRDT Local operations', () => { 5 | let 나; 6 | 7 | beforeEach(() => { 8 | const initialStructure = new LinkedList(); 9 | 나 = new CRDT(1, initialStructure); 10 | }); 11 | 12 | describe('localInsert()', () => { 13 | it('head 위치 삽입에 성공한다.', () => { 14 | // act 15 | 나.localInsert(-1, '녕'); 16 | 나.localInsert(-1, '안'); 17 | 18 | // assert 19 | expect(나.read()).toEqual('안녕'); 20 | }); 21 | 22 | it('tail 위치 삽입에 성공한다.', () => { 23 | // act 24 | 나.localInsert(-1, '안'); 25 | 나.localInsert(0, '녕'); 26 | 27 | // assert 28 | expect(나.read()).toEqual('안녕'); 29 | }); 30 | 31 | it('없는 위치에 삽입 시도 시 에러를 던진다.', () => { 32 | // arrange 33 | 나.localInsert(-1, '안'); 34 | 35 | // act & assert 36 | expect(() => 나.localInsert(1, '녕')).toThrow(); 37 | }); 38 | }); 39 | 40 | describe('localDelete()', () => { 41 | it('tail 위치 삭제에 성공한다.', () => { 42 | // arrange 43 | 나.localInsert(-1, '안'); 44 | 나.localInsert(0, '녕'); 45 | 46 | // act 47 | 나.localDelete(1); 48 | 49 | // assert 50 | expect(나.read()).toEqual('안'); 51 | }); 52 | 53 | it('없는 위치에 삭제 시도 시 에러를 던진다.', () => { 54 | // arrange 55 | 나.localInsert(-1, '안'); 56 | 57 | // act & assert 58 | expect(() => 나.localDelete(1)).toThrow(); 59 | }); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /@wabinar/crdt/test/exception.test.ts: -------------------------------------------------------------------------------- 1 | import CRDT from '../index'; 2 | import LinkedList from '../linked-list'; 3 | import { remoteInsertThroughSocket } from './utils'; 4 | 5 | describe('Exceptions', () => { 6 | let 도훈, 호둔, remoteSites; 7 | 8 | beforeEach(() => { 9 | 도훈 = new CRDT(1, new LinkedList()); 10 | 호둔 = new CRDT(2, new LinkedList()); 11 | 12 | remoteSites = [도훈, 호둔]; 13 | 14 | const 도훈remotes = [ 15 | 도훈.localInsert(-1, '녕'), 16 | 도훈.localInsert(-1, '안'), 17 | ]; 18 | 도훈remotes.forEach((op) => remoteInsertThroughSocket(호둔, op)); 19 | }); 20 | 21 | it('존재하지 않는 인덱스에 localInsert', () => { 22 | expect(() => 도훈.localInsert(100, '.')).toThrow(); 23 | }); 24 | 25 | it('존재하지 않는 노드 뒤에 remoteInsert', () => { 26 | const 도훈remotes = [ 27 | 도훈.localInsert(1, '.'), 28 | 도훈.localInsert(2, '.'), 29 | 도훈.localInsert(3, '.'), 30 | ]; 31 | 32 | // 연속된 remote operation 순서가 섞이는 케이스 33 | expect(() => 호둔.remoteInsert(도훈remotes[1])).toThrow(); 34 | }); 35 | 36 | it('local에서 삭제된 노드 뒤에 삽입', () => { 37 | const 도훈remote = 도훈.localInsert(0, '!'); 38 | const 호둔remote = 호둔.localDelete(0); 39 | 40 | expect(() => 호둔.remoteInsert(도훈remote)).toThrow(); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /@wabinar/crdt/test/utils.ts: -------------------------------------------------------------------------------- 1 | import CRDT from '../index'; 2 | import { RemoteInsertOperation } from '../linked-list'; 3 | import { Node } from '../node'; 4 | 5 | /** 6 | * 모든 remote site 문자열이 일치하는지 확인 7 | */ 8 | export const convergenceCheck = (remoteSites) => { 9 | const convergenceSet = remoteSites.reduce((prev, cur, index) => { 10 | if (index < remoteSites.length - 1) { 11 | prev.push([cur, remoteSites[index + 1]]); 12 | } 13 | 14 | return prev; 15 | }, []); 16 | 17 | convergenceSet.forEach(([first, second]) => { 18 | expect(first.read()).toEqual(second.read()); 19 | }); 20 | }; 21 | 22 | /** 23 | * 바로 전달하면 같은 인스턴스를 가리키게 되어 remote operation 의미가 사라짐 24 | */ 25 | const deepCopyRemoteInsertion = (op: RemoteInsertOperation) => { 26 | const { node } = op; 27 | 28 | const copy = { ...node }; 29 | Object.setPrototypeOf(copy, Node.prototype); 30 | 31 | return { node: copy as Node }; 32 | }; 33 | 34 | export const remoteInsertThroughSocket = ( 35 | crdt: CRDT, 36 | op: RemoteInsertOperation, 37 | ) => { 38 | const copy = deepCopyRemoteInsertion(op); 39 | crdt.remoteInsert(copy); 40 | }; 41 | -------------------------------------------------------------------------------- /@wabinar/crdt/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /client/.env.vault: -------------------------------------------------------------------------------- 1 | ################################################################################# 2 | # # 3 | # This file uniquely identifies your project in dotenv-vault. # 4 | # You SHOULD commit this file to source control. # 5 | # # 6 | # Generated with 'npx dotenv-vault new' # 7 | # # 8 | # Learn more at https://dotenv.org/env-vault # 9 | # # 10 | ################################################################################# 11 | 12 | DOTENV_VAULT=vlt_6f546b9027b83868825eb071a202d75a62a48c1b429093f53d64ed5ecb65ff17 -------------------------------------------------------------------------------- /client/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:react/recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "plugin:import/typescript", 11 | "plugin:import/recommended" 12 | ], 13 | "parser": "@typescript-eslint/parser", 14 | "parserOptions": { 15 | "ecmaFeatures": { 16 | "jsx": true 17 | }, 18 | "ecmaVersion": "latest", 19 | "sourceType": "module" 20 | }, 21 | "plugins": ["react", "@typescript-eslint", "import"], 22 | "settings": { 23 | "react": { 24 | "version": "detect" 25 | } 26 | }, 27 | "rules": { 28 | "react/react-in-jsx-scope": "off", 29 | "react/jsx-filename-extension": [1, { "extensions": [".ts", ".tsx"] }], 30 | "react/no-unknown-property": ["error", { "ignore": ["css"] }], 31 | "no-non-null-assertion": "off", 32 | "no-unused-vars": "off", 33 | "no-nested-ternary": "error", 34 | "eqeqeq": "error", 35 | "no-console": "warn", 36 | "import/no-unresolved": "off", 37 | "import/order": [ 38 | "error", 39 | { 40 | "groups": ["builtin", "external", ["parent", "sibling"], "index"], 41 | "pathGroups": [ 42 | { 43 | "pattern": "angular", 44 | "group": "external", 45 | "position": "before" 46 | } 47 | ], 48 | "alphabetize": { 49 | "order": "asc", 50 | "caseInsensitive": true 51 | }, 52 | "newlines-between": "always" 53 | } 54 | ] 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | .env* 27 | .flaskenv* 28 | !.env.project 29 | !.env.vault -------------------------------------------------------------------------------- /client/.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "stylelint-config-recommended-scss", 4 | "stylelint-config-prettier-scss", 5 | "stylelint-config-property-sort-order-smacss" 6 | ], 7 | "plugins": ["stylelint-scss"], 8 | "rules": { 9 | "at-rule-no-unknown": null, 10 | "max-nesting-depth": 3, 11 | "no-descending-specificity": null, 12 | "string-quotes": "single", 13 | "scss/at-rule-conditional-no-parentheses": null, 14 | "selector-pseudo-class-no-unknown": null, 15 | "property-no-unknown": null 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | Wabinar 26 | 27 | 28 |
29 |
30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /client/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', // to use typescript 3 | verbose: true, 4 | modulePathIgnorePatterns: ['/dist/'], 5 | collectCoverage: true, 6 | }; 7 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "private": true, 4 | "version": "0.0.0", 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "tsc && vite build", 8 | "test": "jest", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@react-icons/all-files": "^4.1.0", 13 | "@tanstack/react-query": "^4.29.12", 14 | "@types/react-helmet": "^6.1.6", 15 | "axios": "^1.1.3", 16 | "classnames": "^2.3.2", 17 | "eventemitter3": "^5.0.0", 18 | "react": "^18.2.0", 19 | "react-dom": "^18.2.0", 20 | "react-helmet-async": "^1.3.0", 21 | "react-loader-spinner": "^5.3.4", 22 | "react-router-dom": "^6.4.3", 23 | "react-toastify": "^9.1.1", 24 | "socket.io-client": "^4.5.4", 25 | "uuid": "^9.0.0" 26 | }, 27 | "devDependencies": { 28 | "@testing-library/jest-dom": "^5.16.5", 29 | "@types/react": "^18.0.24", 30 | "@types/react-dom": "^18.0.8", 31 | "@types/testing-library__jest-dom": "^5", 32 | "@vitejs/plugin-react": "^2.2.0", 33 | "eslint-plugin-react": "latest", 34 | "sass": "^1.56.1", 35 | "stylelint": "^14.15.0", 36 | "stylelint-config-prettier-scss": "^0.0.1", 37 | "stylelint-config-property-sort-order-smacss": "^9.0.0", 38 | "stylelint-config-recommended-scss": "^8.0.0" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /client/public/og-img.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web27-Wabinar/e102dae3e68ade71056f97f5d3c0b32afda4e898/client/public/og-img.png -------------------------------------------------------------------------------- /client/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { lazy, Suspense, useEffect, useState } from 'react'; 2 | import { Route, Routes, useLocation, useNavigate } from 'react-router-dom'; 3 | import { getAuth } from 'src/apis/auth'; 4 | import UserContext from 'src/contexts/user'; 5 | import { User } from 'src/types/user'; 6 | 7 | import 'styles/reset.scss'; 8 | 9 | const LoginPage = lazy(() => import('src/pages/Login')); 10 | const OAuthPage = lazy(() => import('src/pages/OAuth')); 11 | const WorkspacePage = lazy(() => import('src/pages/Workspace')); 12 | const NotFoundPage = lazy(() => import('src/pages/404')); 13 | const LoadingPage = lazy(() => import('src/pages/Loading')); 14 | 15 | function App() { 16 | const [user, setUser] = useState(null); 17 | const [isLoaded, setIsLoaded] = useState(false); 18 | 19 | const { pathname } = useLocation(); 20 | const navigate = useNavigate(); 21 | 22 | const autoLogin = async () => { 23 | const { user } = await getAuth(); 24 | 25 | setIsLoaded(true); 26 | setUser(user); 27 | 28 | const validPathPattern = /^\/workspace(\/\d+(\/.+)?)?$/; // /workspace(/숫자(/아무거나)) 와 처음부터 끝까지 일치하는 패턴 29 | if (user && !validPathPattern.test(pathname)) { 30 | navigate('/workspace'); 31 | } 32 | }; 33 | 34 | useEffect(() => { 35 | autoLogin(); 36 | }, []); 37 | 38 | return ( 39 | }> 40 | {isLoaded ? ( 41 | 42 | 43 | } /> 44 | } /> 45 | } /> 46 | } /> 47 | 48 | 49 | ) : ( 50 | 51 | )} 52 | 53 | ); 54 | } 55 | 56 | export default App; 57 | -------------------------------------------------------------------------------- /client/src/apis/auth.ts: -------------------------------------------------------------------------------- 1 | import { LoginResBody, PostLoginBody } from '@wabinar/api-types/auth'; 2 | 3 | import { http } from './http'; 4 | import { CREATED, OK } from './http-status'; 5 | 6 | export const getAuth = async (): Promise => { 7 | const res = await http.get(`/auth`); 8 | 9 | if (res.status !== OK) throw new Error(); 10 | 11 | return res.data; 12 | }; 13 | 14 | export const postAuthLogin = async ({ 15 | code, 16 | }: PostLoginBody): Promise => { 17 | const res = await http.post(`/auth/login`, { code }); 18 | 19 | if (res.status !== CREATED) throw new Error(); 20 | 21 | return res.data; 22 | }; 23 | 24 | export const deleteAuthlogout = async () => { 25 | const res = await http.delete(`/auth/logout`); 26 | 27 | if (res.status !== OK) throw new Error(); 28 | 29 | return; 30 | }; 31 | -------------------------------------------------------------------------------- /client/src/apis/http-status.ts: -------------------------------------------------------------------------------- 1 | export const OK = 200; 2 | export const CREATED = 201; 3 | 4 | export const BAD_REQUEST = 400; 5 | export const UNAUTHORIZED = 401; 6 | 7 | export const INTERNAL_SERVER_ERROR = 500; 8 | -------------------------------------------------------------------------------- /client/src/apis/http.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import env from 'config'; 3 | import { toast } from 'react-toastify'; 4 | 5 | const SERVER_PATH = env.SERVER_PATH; 6 | 7 | export const http = axios.create({ 8 | baseURL: SERVER_PATH, 9 | withCredentials: true, 10 | }); 11 | 12 | http.interceptors.response.use( 13 | (res) => res, 14 | (err) => { 15 | if (err.response && err.response.status) { 16 | switch (err.response.status) { 17 | case 401: 18 | if (window.location.pathname !== '/') window.location.href = '/'; 19 | toast('다시 로그인해주세요 .. ^^', { type: 'warning' }); 20 | break; 21 | case 403: 22 | if (window.location.pathname !== '/') window.location.href = '/'; 23 | toast('다시 로그인해주세요 .. ^^', { type: 'warning' }); 24 | break; 25 | case 404: 26 | window.location.href = '/404'; 27 | break; 28 | case 500: 29 | toast('500 Network Error .. ^^', { type: 'error' }); 30 | break; 31 | default: 32 | return; 33 | } 34 | } 35 | }, 36 | ); 37 | -------------------------------------------------------------------------------- /client/src/apis/user.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GetWorkspaceParams, 3 | GetWorkspacesResBody, 4 | } from '@wabinar/api-types/user'; 5 | 6 | import { http } from './http'; 7 | import { OK } from './http-status'; 8 | 9 | export const getWorkspaces = async ({ 10 | id, 11 | }: GetWorkspaceParams): Promise => { 12 | const res = await http.get(`/user/${id}/workspace`); 13 | 14 | if (res.status !== OK) throw new Error(); 15 | 16 | return res.data; 17 | }; 18 | -------------------------------------------------------------------------------- /client/src/apis/workspace.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GetInfoParams, 3 | PostJoinBody, 4 | PostBody, 5 | } from '@wabinar/api-types/workspace'; 6 | import { Workspace, WorkspaceInfo } from 'src/types/workspace'; 7 | 8 | import { http } from './http'; 9 | import { CREATED, OK } from './http-status'; 10 | 11 | export const postWorkspace = async ({ name }: PostBody): Promise => { 12 | const res = await http.post(`/workspace`, { name }); 13 | 14 | if (res.status !== CREATED) throw new Error(); 15 | 16 | return res.data; 17 | }; 18 | 19 | export const postWorkspaceJoin = async ({ 20 | code, 21 | }: PostJoinBody): Promise => { 22 | const res = await http.post(`/workspace/join`, { code }); 23 | 24 | if (res.status !== CREATED) throw new Error(); 25 | 26 | return res.data; 27 | }; 28 | 29 | export const getWorkspaceInfo = async ({ 30 | id, 31 | }: Partial): Promise => { 32 | if (!id) return null; 33 | 34 | const res = await http.get(`/workspace/${id}`); 35 | 36 | if (res.status !== OK) throw new Error(); 37 | 38 | return res.data; 39 | }; 40 | -------------------------------------------------------------------------------- /client/src/components/Block/QuestionBlock/style.module.scss: -------------------------------------------------------------------------------- 1 | @import 'styles/color.module'; 2 | 3 | .question-container { 4 | width: 100%; 5 | 6 | padding: 20px; 7 | 8 | border-radius: 12px; 9 | 10 | background-color: $white; 11 | box-shadow: 1px 4px 12px rgba(0, 0, 0, 0.2); 12 | .title { 13 | font-size: 16px; 14 | font-weight: 700; 15 | 16 | text-align: center; 17 | } 18 | 19 | .question-item { 20 | display: flex; 21 | flex-direction: row; 22 | align-items: center; 23 | 24 | margin: 8px 0px; 25 | 26 | cursor: pointer; 27 | 28 | .check-box { 29 | width: 25px; 30 | height: 25px; 31 | 32 | margin-right: 12px; 33 | } 34 | 35 | .question { 36 | font-size: 14px; 37 | font-weight: 400; 38 | } 39 | 40 | .check { 41 | color: $gray-200; 42 | text-decoration: line-through; 43 | } 44 | 45 | &:hover { 46 | border-radius: 6px; 47 | background-color: $gray-300; 48 | } 49 | 50 | &:active { 51 | background-color: $gray-200; 52 | } 53 | } 54 | 55 | .question-input { 56 | width: 100%; 57 | background-color: transparent; 58 | font-size: 14px; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /client/src/components/Block/VoteBlock/index.tsx: -------------------------------------------------------------------------------- 1 | import { BLOCK_EVENT } from '@wabinar/constants/socket-message'; 2 | import { useEffect, useState } from 'react'; 3 | import { VoteMode } from 'src/constants/block'; 4 | import useSocketContext from 'src/hooks/context/useSocketContext'; 5 | import { Option } from 'src/types/block'; 6 | 7 | import VoteBlockTemplate from './VoteBlockTemplate'; 8 | 9 | interface VoteBlockProps { 10 | id: string; 11 | registerable: boolean; 12 | } 13 | 14 | function VoteBlock({ id, registerable }: VoteBlockProps) { 15 | const { momSocket: socket } = useSocketContext(); 16 | 17 | const [voteMode, setVoteMode] = useState( 18 | registerable ? VoteMode.REGISTERING : VoteMode.CREATE, 19 | ); 20 | const initialOption: Option[] = [{ id: 1, text: '', count: 0 }]; 21 | const [options, setOptions] = useState(initialOption); 22 | 23 | useEffect(() => { 24 | socket.on(`${BLOCK_EVENT.REGISTER_VOTE}-${id}`, (options) => { 25 | setVoteMode(VoteMode.REGISTERED as VoteMode); 26 | setOptions(options); 27 | }); 28 | }, []); 29 | 30 | return ( 31 | 38 | ); 39 | } 40 | 41 | export default VoteBlock; 42 | -------------------------------------------------------------------------------- /client/src/components/Block/VoteBlock/style.module.scss: -------------------------------------------------------------------------------- 1 | @import 'styles/color.module'; 2 | 3 | .vote-container { 4 | width: 100%; 5 | padding: 20px; 6 | 7 | border-radius: 12px; 8 | background-color: $white; 9 | box-shadow: 1px 4px 12px rgba(0, 0, 0, 0.2); 10 | 11 | .title { 12 | font-size: 16px; 13 | font-weight: 700; 14 | text-align: center; 15 | } 16 | 17 | .participant-cnt { 18 | display: block; 19 | font-size: 12px; 20 | text-align: right; 21 | } 22 | 23 | .option-item { 24 | display: flex; 25 | position: relative; 26 | z-index: 1; 27 | align-items: center; 28 | 29 | width: 100%; 30 | height: 50px; 31 | 32 | margin: 14px 0px; 33 | padding: 0; 34 | 35 | overflow: hidden; 36 | border: 4px solid initial; 37 | 38 | border-radius: 12px; 39 | box-shadow: 1px 1px 6px rgba(0, 0, 0, 0.2); 40 | 41 | cursor: pointer; 42 | 43 | .vote-result-bar { 44 | position: absolute; 45 | z-index: -1; 46 | height: 50px; 47 | } 48 | 49 | .box-fill { 50 | display: flex; 51 | align-items: center; 52 | justify-content: center; 53 | 54 | min-width: 30px; 55 | min-height: 30px; 56 | 57 | margin-right: 12px; 58 | margin-left: 10px; 59 | border-radius: 4px; 60 | background-color: $primary-100; 61 | color: $white; 62 | font-size: 16px; 63 | } 64 | 65 | .option-input { 66 | flex-grow: 1; 67 | background-color: transparent; 68 | } 69 | .cursor-enable { 70 | cursor: pointer; 71 | } 72 | 73 | .position-right { 74 | position: absolute; 75 | right: 0; 76 | } 77 | 78 | .vote-result-text { 79 | margin-right: 10px; 80 | margin-left: auto; 81 | font-size: 12px; 82 | text-align: right; 83 | } 84 | } 85 | 86 | .selected-item { 87 | border: 4px solid $highlight-200; 88 | } 89 | 90 | .vote-buttons { 91 | display: flex; 92 | flex-direction: row; 93 | justify-content: end; 94 | margin-top: 20px; 95 | 96 | button { 97 | display: flex; 98 | align-items: center; 99 | justify-content: center; 100 | 101 | width: 100px; 102 | height: 30px; 103 | margin-left: 12px; 104 | padding: 0px; 105 | 106 | border-radius: 8px; 107 | background-color: $primary-100; 108 | color: $white; 109 | 110 | span { 111 | padding: 0px; 112 | } 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /client/src/components/Block/index.tsx: -------------------------------------------------------------------------------- 1 | import { BiPlus } from '@react-icons/all-files/bi/BiPlus'; 2 | import * as BlockMessage from '@wabinar/api-types/block'; 3 | import { BLOCK_EVENT } from '@wabinar/constants/socket-message'; 4 | import ee from 'components/Mom/EventEmitter'; 5 | import { memo, useEffect, useRef, useState } from 'react'; 6 | import useSocketContext from 'src/hooks/context/useSocketContext'; 7 | 8 | import QuestionBlock from './QuestionBlock'; 9 | import style from './style.module.scss'; 10 | import TextBlock from './TextBlock'; 11 | import VoteBlock from './VoteBlock'; 12 | 13 | export enum BlockType { 14 | H1, 15 | H2, 16 | H3, 17 | P, 18 | VOTE, 19 | QUESTION, 20 | } 21 | 22 | interface BlockProps { 23 | id: string; 24 | index: number; 25 | createBlock: (arg: number) => void; 26 | onHandleBlocks: React.KeyboardEventHandler; 27 | registerRef: (arg: React.RefObject) => void; 28 | } 29 | 30 | function Block({ 31 | id, 32 | index, 33 | createBlock, 34 | onHandleBlocks, 35 | registerRef, 36 | }: BlockProps) { 37 | const { momSocket: socket } = useSocketContext(); 38 | 39 | const [type, setType] = useState(); 40 | const localUpdateFlagRef = useRef(false); 41 | 42 | useEffect(() => { 43 | const message: BlockMessage.LoadType = { id }; 44 | const callback = ({ type }: BlockMessage.LoadedType) => setType(type); 45 | 46 | socket.emit(BLOCK_EVENT.LOAD_TYPE, message, callback); 47 | 48 | ee.on(`${BLOCK_EVENT.UPDATE_TYPE}-${id}`, (type) => { 49 | setType(type); 50 | localUpdateFlagRef.current = false; 51 | }); 52 | }, []); 53 | 54 | useEffect(() => { 55 | if (localUpdateFlagRef.current && type) { 56 | const message: BlockMessage.UpdateType = { id, type }; 57 | socket.emit(BLOCK_EVENT.UPDATE_TYPE, message); 58 | } 59 | }, [type]); 60 | 61 | const setBlockType = (type: BlockType) => { 62 | localUpdateFlagRef.current = true; 63 | setType(type); 64 | 65 | if ([BlockType.VOTE, BlockType.QUESTION].includes(type)) { 66 | createBlock(index); 67 | } 68 | }; 69 | 70 | const onCreate = () => createBlock(index); 71 | 72 | const getCurrentBlock = () => { 73 | switch (type) { 74 | case BlockType.H1: 75 | case BlockType.H2: 76 | case BlockType.H3: 77 | case BlockType.P: 78 | return ( 79 | 88 | ); 89 | case BlockType.VOTE: 90 | return ; 91 | case BlockType.QUESTION: 92 | return ; 93 | default: 94 | return <>; 95 | } 96 | }; 97 | 98 | return ( 99 |
100 | 101 | {getCurrentBlock()} 102 |
103 | ); 104 | } 105 | 106 | function isMemoized(prev: BlockProps, next: BlockProps) { 107 | return prev.id === next.id; 108 | } 109 | 110 | export default memo(Block, isMemoized); 111 | -------------------------------------------------------------------------------- /client/src/components/Block/style.module.scss: -------------------------------------------------------------------------------- 1 | @import 'styles/color.module'; 2 | 3 | @mixin heading-style($border-size, $padding) { 4 | padding: $padding 0px; 5 | border-bottom: $border-size solid $gray-300; 6 | } 7 | 8 | .block { 9 | position: relative; 10 | width: 100%; 11 | 12 | & > h1, 13 | & > h2, 14 | & > h3, 15 | & > p { 16 | width: 100%; 17 | word-break: break-all; 18 | } 19 | 20 | & > *:focus { 21 | outline: none; 22 | } 23 | 24 | & > h1 { 25 | font-size: 36px; 26 | font-weight: 700; 27 | @include heading-style(1.5px, 8px); 28 | } 29 | 30 | & > h2 { 31 | font-size: 28px; 32 | font-weight: 600; 33 | @include heading-style(1px, 6px); 34 | } 35 | 36 | & > h3 { 37 | font-size: 24px; 38 | font-weight: 500; 39 | @include heading-style(0.5px, 4px); 40 | } 41 | 42 | & > p { 43 | font-size: initial; 44 | font-weight: 400; 45 | line-height: 30px; 46 | } 47 | 48 | & > svg { 49 | position: absolute; 50 | transform: translate(-20px, 2px); 51 | fill: none; 52 | } 53 | 54 | & > svg:hover { 55 | fill: $gray-200; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /client/src/components/BlockSelector/BlockItem/index.tsx: -------------------------------------------------------------------------------- 1 | import { BlockType } from '@wabinar/constants/block'; 2 | 3 | import style from './style.module.scss'; 4 | interface BlockItemProps { 5 | id: number; 6 | name: string; 7 | desc: string; 8 | thumbnail: string; 9 | onSelect: (arg: BlockType) => void; 10 | } 11 | 12 | function BlockItem({ id, name, desc, thumbnail, onSelect }: BlockItemProps) { 13 | return ( 14 |
  • onSelect(id)}> 15 | {name 16 | 17 |
    18 |
    {name}
    19 | {desc} 20 |
    21 |
  • 22 | ); 23 | } 24 | 25 | export default BlockItem; 26 | -------------------------------------------------------------------------------- /client/src/components/BlockSelector/BlockItem/style.module.scss: -------------------------------------------------------------------------------- 1 | @import 'styles/color.module'; 2 | 3 | .block-item { 4 | display: flex; 5 | margin: 5px; 6 | padding: 5px; 7 | font-size: 12px; 8 | cursor: pointer; 9 | 10 | &:hover { 11 | background-color: $gray-300-transparent; 12 | } 13 | 14 | img { 15 | width: 46px; 16 | height: 46px; 17 | background-color: $white; 18 | box-shadow: rgb(15 15 15 / 10%) 0px 0px 0px 1px; 19 | } 20 | 21 | .text { 22 | flex: 1 1 auto; 23 | margin-left: 6px; 24 | .name { 25 | color: $black; 26 | } 27 | .desc { 28 | color: $gray-100; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /client/src/components/BlockSelector/index.tsx: -------------------------------------------------------------------------------- 1 | import { BlockType } from '@wabinar/constants/block'; 2 | import { BLOCKS_TYPE } from 'src/constants/block'; 3 | 4 | import BlockItem from './BlockItem'; 5 | import style from './style.module.scss'; 6 | 7 | interface BlockSelectorProps { 8 | onClose: () => void; 9 | onSelect: (arg: BlockType) => void; 10 | } 11 | 12 | function BlockSelector({ onClose, onSelect }: BlockSelectorProps) { 13 | return ( 14 | <> 15 |
    16 |
    17 | 블록 18 | 19 |
      20 | {BLOCKS_TYPE.map(({ id, name, desc, thumbnail }) => ( 21 | 25 | ))} 26 |
    27 |
    28 |
    29 |
    30 | 31 | ); 32 | } 33 | 34 | export default BlockSelector; 35 | -------------------------------------------------------------------------------- /client/src/components/BlockSelector/style.module.scss: -------------------------------------------------------------------------------- 1 | @import 'styles/color.module'; 2 | 3 | .block-selector { 4 | position: absolute; 5 | z-index: 99; 6 | height: 280px; 7 | overflow: hidden; 8 | 9 | .block-displayed { 10 | display: flex; 11 | flex-direction: column; 12 | min-width: 300px; 13 | max-height: 280px; 14 | padding: 5px; 15 | overflow: auto; 16 | border: 1px solid $gray-300-transparent; 17 | border-radius: 4px; 18 | background-color: $white; 19 | box-shadow: rgb(15 15 15 / 5%) 0px 0px 0px 1px, 20 | $gray-300-transparent 0px 3px 6px, $gray-300-transparent 0px 9px 24px; 21 | 22 | strong { 23 | margin-bottom: 5px; 24 | margin-left: 5px; 25 | color: $gray-100; 26 | font-size: 12px; 27 | } 28 | } 29 | } 30 | 31 | .dimmer { 32 | position: fixed; 33 | top: 0; 34 | left: 0; 35 | width: 100vw; 36 | height: 100vh; 37 | } 38 | -------------------------------------------------------------------------------- /client/src/components/MeetingMediaBar/MeetingMedia/index.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useEffect, useRef } from 'react'; 2 | 3 | import style from './style.module.scss'; 4 | 5 | interface MediaProps { 6 | stream: MediaStream; 7 | muted: boolean; 8 | } 9 | 10 | function MeetingMedia({ stream, muted }: MediaProps) { 11 | const ref = useRef(null); 12 | 13 | useEffect(() => { 14 | if (!ref.current) return; 15 | ref.current.srcObject = stream; 16 | }, [stream]); 17 | 18 | return