├── .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