├── .github ├── ISSUE_TEMPLATE │ ├── bug-fix-template.md │ ├── feature-template.md │ ├── refactor-template.md │ ├── request-template.md │ └── 🚨-bug-report-template.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── auto_merge_approved_pr.yml │ ├── deploy.yml │ ├── lint_and_test.yml │ └── lint_and_unit_test.yml ├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── @noctaCrdt ├── Crdt.ts ├── Interfaces.ts ├── LinkedList.ts ├── Node.ts ├── NodeId.ts ├── Page.ts ├── WorkSpace.ts ├── package.json └── tsconfig.json ├── README.md ├── client ├── .gitignore ├── Dockerfile ├── Dockerfile.dev ├── README.md ├── eslint.config.js ├── index.html ├── package.json ├── panda.config.ts ├── postcss.config.cjs ├── public │ ├── images │ │ └── nocta.png │ ├── robots.txt │ └── sitemap.xml ├── src │ ├── App.tsx │ ├── apis │ │ ├── auth.ts │ │ └── axios.ts │ ├── assets │ │ ├── fonts │ │ │ ├── Pretendard-Bold.woff │ │ │ ├── Pretendard-Bold.woff2 │ │ │ ├── Pretendard-Medium.woff │ │ │ └── Pretendard-Medium.woff2 │ │ ├── icons │ │ │ ├── close.svg │ │ │ ├── draggable.svg │ │ │ ├── expand.svg │ │ │ ├── lock.svg │ │ │ ├── mail.svg │ │ │ ├── minus.svg │ │ │ ├── noctaDayIcon.svg │ │ │ ├── noctaNightIcon.svg │ │ │ ├── pencil.svg │ │ │ ├── plusIcon.svg │ │ │ └── user.svg │ │ ├── images │ │ │ ├── background.avif │ │ │ ├── background.png │ │ │ └── background.webp │ │ └── lotties │ │ │ ├── errorAlert.json │ │ │ ├── loadingSpinner.json │ │ │ ├── noctaDayIcon.json │ │ │ └── noctaNightIcon.json │ ├── components │ │ ├── Toast │ │ │ ├── Toast.style.ts │ │ │ ├── Toast.tsx │ │ │ ├── ToastContainer.style.ts │ │ │ └── ToastContainer.tsx │ │ ├── backgroundImage │ │ │ └── BackgroundImage.tsx │ │ ├── bottomNavigator │ │ │ ├── BottomNavigator.animation.ts │ │ │ ├── BottomNavigator.style.ts │ │ │ └── BottomNavigator.tsx │ │ ├── button │ │ │ ├── IconButton.style.ts │ │ │ ├── IconButton.tsx │ │ │ ├── textButton.style.ts │ │ │ └── textButton.tsx │ │ ├── inputField │ │ │ ├── InputField.style.ts │ │ │ └── InputField.tsx │ │ ├── lotties │ │ │ ├── ErrorAlert.tsx │ │ │ ├── LoadingSpinner.tsx │ │ │ └── NoctaIcon.tsx │ │ ├── modal │ │ │ ├── ErrorModal.style.ts │ │ │ ├── ErrorModal.tsx │ │ │ ├── InviteModal.style.ts │ │ │ ├── InviteModal.tsx │ │ │ ├── RenameModal.style.ts │ │ │ ├── RenameModal.tsx │ │ │ ├── modal.animation.ts │ │ │ ├── modal.style.ts │ │ │ ├── modal.tsx │ │ │ └── useModal.ts │ │ └── sidebar │ │ │ ├── Sidebar.animation.ts │ │ │ ├── Sidebar.style.ts │ │ │ ├── Sidebar.tsx │ │ │ └── components │ │ │ ├── menuButton │ │ │ ├── MenuButton.style.ts │ │ │ ├── MenuButton.tsx │ │ │ └── components │ │ │ │ ├── MenuIcon.style.ts │ │ │ │ ├── MenuIcon.tsx │ │ │ │ ├── WorkspaceSelectModal.style.ts │ │ │ │ ├── WorkspaceSelectModal.tsx │ │ │ │ └── components │ │ │ │ ├── InviteButton.style.ts │ │ │ │ ├── InviteButton.tsx │ │ │ │ ├── WorkspaceSelectItem.style.tsx │ │ │ │ └── WorkspaceSelectItem.tsx │ │ │ ├── pageIconButton │ │ │ ├── PageIconButton.style.ts │ │ │ ├── PageIconButton.tsx │ │ │ ├── PageIconModal.tsx │ │ │ └── pageIconModal.style.ts │ │ │ └── pageItem │ │ │ ├── PageItem.style.ts │ │ │ └── PageItem.tsx │ ├── constants │ │ ├── PageIconButton.config.ts │ │ ├── color.ts │ │ ├── option.ts │ │ ├── page.ts │ │ ├── size.ts │ │ └── spacing.ts │ ├── features │ │ ├── auth │ │ │ ├── AuthButton.style.ts │ │ │ ├── AuthButton.tsx │ │ │ ├── AuthModal.style.ts │ │ │ └── AuthModal.tsx │ │ ├── editor │ │ │ ├── Editor.style.ts │ │ │ ├── Editor.tsx │ │ │ ├── components │ │ │ │ ├── ColorOptionModal │ │ │ │ │ ├── BackgroundColorOptionModal.style.ts │ │ │ │ │ ├── BackgroundColorOptionModal.tsx │ │ │ │ │ ├── TextColorOptionModal.style.ts │ │ │ │ │ └── TextColorOptionModal.tsx │ │ │ │ ├── IconBlock │ │ │ │ │ ├── IconBlock.style.ts │ │ │ │ │ └── IconBlock.tsx │ │ │ │ ├── MenuBlock │ │ │ │ │ ├── MenuBlock.style.ts │ │ │ │ │ └── MenuBlock.tsx │ │ │ │ ├── OptionModal │ │ │ │ │ ├── OptionModal.animaiton.ts │ │ │ │ │ ├── OptionModal.style.ts │ │ │ │ │ └── OptionModal.tsx │ │ │ │ ├── TextOptionModal │ │ │ │ │ ├── TextOptionModal.style.ts │ │ │ │ │ └── TextOptionModal.tsx │ │ │ │ ├── TypeOptionModal │ │ │ │ │ └── TypeOptionModal.tsx │ │ │ │ └── block │ │ │ │ │ ├── Block.animation.ts │ │ │ │ │ ├── Block.style.ts │ │ │ │ │ └── Block.tsx │ │ │ ├── hooks │ │ │ │ ├── useBlockAnimtaion.ts │ │ │ │ ├── useBlockDragAndDrop.ts │ │ │ │ ├── useBlockOperation.ts │ │ │ │ ├── useBlockOption.ts │ │ │ │ ├── useCopyAndPaste.ts │ │ │ │ ├── useEditorOperation.ts │ │ │ │ ├── useMarkdownGrammer.ts │ │ │ │ └── useTextOptions.ts │ │ │ └── utils │ │ │ │ ├── domSyncUtils.ts │ │ │ │ └── markdownPatterns.ts │ │ ├── page │ │ │ ├── Page.animation.ts │ │ │ ├── Page.style.ts │ │ │ ├── Page.tsx │ │ │ ├── components │ │ │ │ ├── PageControlButton │ │ │ │ │ ├── PageControlButton.style.ts │ │ │ │ │ └── PageControlButton.tsx │ │ │ │ └── PageTitle │ │ │ │ │ ├── PageTitle.style.ts │ │ │ │ │ └── PageTitle.tsx │ │ │ └── hooks │ │ │ │ └── usePage.ts │ │ └── workSpace │ │ │ ├── WorkSpace.style.ts │ │ │ ├── WorkSpace.tsx │ │ │ ├── components │ │ │ ├── IntroScreen.animation.ts │ │ │ ├── IntroScreen.style.ts │ │ │ ├── IntroScreen.tsx │ │ │ ├── OnboardingOverlay.style.ts │ │ │ └── OnboardingOverlay.tsx │ │ │ └── hooks │ │ │ ├── usePagesManage.ts │ │ │ └── useWorkspaceInit.ts │ ├── index.css │ ├── main.tsx │ ├── stores │ │ ├── useErrorStore.ts │ │ ├── useSidebarStore.ts │ │ ├── useSocketStore.ts │ │ ├── useToastStore.ts │ │ ├── useUserStore.ts │ │ └── useWorkspaceStore.ts │ ├── styles │ │ ├── global.ts │ │ ├── recipes │ │ │ └── glassContainerRecipe.ts │ │ ├── tokens │ │ │ ├── color.ts │ │ │ ├── radii.ts │ │ │ ├── shadow.ts │ │ │ ├── sizes.ts │ │ │ └── spacing.ts │ │ └── typography.ts │ ├── types │ │ ├── markdown.ts │ │ ├── page.ts │ │ └── toast.ts │ ├── utils │ │ └── caretUtils.ts │ └── vite-env.d.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json ├── tsconfig.tsbuildinfo ├── vite-env-override.d.ts ├── vite.config.ts └── vite.env.d.ts ├── docker-compose.debug.yml ├── docker-compose.yml ├── eslint.config.js ├── nginx ├── Dockerfile ├── Dockerfile.dev ├── default.conf └── default.dev.conf ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── server ├── .gitignore ├── Dockerfile ├── Dockerfile.dev ├── README.md ├── eslint.config.js ├── jest-mongodb-config.ts ├── jest.config.ts ├── nest-cli.json ├── package.json ├── src │ ├── @types │ │ └── express │ │ │ └── index.d.ts │ ├── app.controller.spec.ts │ ├── app.controller.ts │ ├── app.module.spec.ts │ ├── app.module.ts │ ├── app.service.ts │ ├── auth │ │ ├── auth.controller.ts │ │ ├── auth.interface.ts │ │ ├── auth.module.ts │ │ ├── auth.service.ts │ │ ├── dto │ │ │ └── user.dto.ts │ │ ├── guards │ │ │ ├── jwt-auth.guard.ts │ │ │ └── jwt-refresh-token-auth.guard.ts │ │ ├── schemas │ │ │ └── user.schema.ts │ │ ├── strategies │ │ │ ├── jwt-refresh-token.strategy.ts │ │ │ └── jwt.strategy.ts │ │ └── test │ │ │ ├── auth.controller.spec.ts │ │ │ ├── auth.module.spec.ts │ │ │ └── auth.service.spec.ts │ ├── main.ts │ ├── swagger │ │ └── swagger.config.ts │ └── workspace │ │ ├── schemas │ │ └── workspace.schema.ts │ │ ├── workspace.controller.spec.ts │ │ ├── workspace.controller.ts │ │ ├── workspace.gateway.ts │ │ ├── workspace.interface.ts │ │ ├── workspace.module.ts │ │ └── workspace.service.ts ├── test │ ├── app.e2e-spec.ts │ └── jest-e2e.json ├── tsconfig.build.json ├── tsconfig.json └── webpack.config.js └── tsconfig.base.json /.github/ISSUE_TEMPLATE/bug-fix-template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Fix Template 3 | about: 버그 픽스 템플릿 4 | title: '' 5 | labels: Bug 6 | assignees: '' 7 | --- 8 | 9 | ## 🚨 버그 내용 10 | 11 | - 어떤 상황에서 일어난 버그인지 작성해주세요. 12 | 13 | ## 예상 결과 14 | 15 | - 예상했던 결과가 어떤 것이었는지 작성해주세요. 16 | 17 | ## 예상 소요 시간 (선택) 18 | 19 | - 해결하기까지 예상되는 소요 시간을 작성해주세요. 20 | 21 | ## 참고 자료 (선택) 22 | 23 | - 버그를 해결하기 위한 참고자료를 작성해주세요. 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Template 3 | about: 기능 추가 템플릿 4 | title: '' 5 | labels: Feat 6 | assignees: '' 7 | --- 8 | 9 | ## ✏️ 기능 10 | 11 | - 추가하려는 기능에 대해 설명해주세요. 12 | 13 | ## 작업 내용 14 | 15 | - [ ] 내용 16 | 17 | ## 예상 소요 시간 (선택) 18 | 19 | - 완료하기까지 예상되는 소요 시간을 작성해주세요. 20 | 21 | ## 참고 자료 (선택) 22 | 23 | - 기능을 구현하기 위한 참고자료를 작성해주세요. 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/refactor-template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Refactor Template 3 | about: 리팩토링 템플릿 4 | title: '' 5 | labels: Refactor 6 | assignees: '' 7 | --- 8 | 9 | ## 🛠️ 리팩토링 내용 10 | 11 | - 리팩토링 기능에 대해 설명해주세요. 12 | 13 | ## 작업 내용 14 | 15 | - [ ] 내용 16 | 17 | ## 예상 소요 시간 (선택) 18 | 19 | - 완료하기까지 예상되는 소요 시간을 작성해주세요. 20 | 21 | ## 참고 자료 (선택) 22 | 23 | - 기능을 구현하기 위한 참고자료를 작성해주세요. 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/request-template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Request Template 3 | about: "\U0001F3A4 불편사항 제보 템플릿" 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | ## 🎤 불편사항 내용 10 | 11 | - 어떤 불편사항이 있는지 작성해주세요. 12 | 13 | ## 개선 방향 14 | 15 | - 기대하는 개선 방향에 대해 알려주세요. 16 | 17 | ## 스크린샷 (선택) 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/🚨-bug-report-template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F6A8 Bug Report Template" 3 | about: 버그 제보 템플릿 4 | title: '' 5 | labels: Bug 6 | assignees: '' 7 | --- 8 | 9 | ## 🚨 버그 내용 10 | 11 | - 어떤 상황에서 일어난 버그인지 작성해주세요. 12 | 13 | ## 버그 발생 상황 14 | 15 | - 어떤 상황에서 일어난 버그인지 설명 해주세요. 16 | - 추가적으로, 기대했던 동작은 무엇이었는지 설명해주세요. 17 | 18 | ## 접속중인 환경 19 | 20 | - [ ] PC 21 | - [ ] 모바일 22 | 23 | ### 사용 중인 브라우저 및 버전 24 | 25 | - ex. chrome 125.0 26 | 27 | ### 사용 중인 기기명 및 OS 버전 28 | 29 | - ex. iPhone 15 Pro Max, iOS 17.2.1 30 | 31 | ## 스크린샷 (선택) 32 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## 📝 변경 사항 2 | 3 | - 변경된 주요 사항을 bullet point로 정리하여 작성합니다. 4 | 5 | ## 🔍 변경 사항 설명 6 | 7 | - 변경 사항이 코드에 어떻게 반영되었는지, 특히 리뷰어가 주의해야 할 부분이 있다면 상세히 설명합니다. 8 | 9 | ## 🙏 질문 사항 10 | 11 | - [ ] 리뷰어에게 부탁하고싶은 체크리스트를 추가합니다. 12 | 13 | ## 📷 스크린샷 (선택) 14 | 15 | - UI 변경이 있는 경우 스크린샷이나 GIF를 첨부합니다. 16 | 17 | ## ✅ 작성자 체크리스트 18 | 19 | - [ ] Self-review: 코드가 스스로 검토됨 20 | - [ ] Unit tests 추가 또는 수정 21 | - [ ] 로컬에서 모든 기능이 정상 작동함 22 | - [ ] 린터 및 포맷터로 코드 정리됨 23 | - [ ] 의존성 업데이트 확인 24 | - [ ] 문서 업데이트 또는 주석 추가 (필요 시) 25 | -------------------------------------------------------------------------------- /.github/workflows/auto_merge_approved_pr.yml: -------------------------------------------------------------------------------- 1 | name: "Auto Merge Approved PRs" 2 | 3 | on: 4 | pull_request_review: 5 | types: [submitted] 6 | 7 | jobs: 8 | auto_merge: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: "Check Approvals" 12 | id: check 13 | uses: actions/github-script@v6 14 | with: 15 | github-token: ${{ secrets.GITHUB_TOKEN }} 16 | result-encoding: string 17 | script: | 18 | const pull_number = context.payload.pull_request.number; 19 | const reviews = await github.rest.pulls.listReviews({ 20 | owner: context.repo.owner, 21 | repo: context.repo.repo, 22 | pull_number: pull_number, 23 | }); 24 | const approvals = reviews.data.filter(review => review.state === 'APPROVED'); 25 | const approvalCount = approvals.length; 26 | core.info(`PR #${pull_number} has ${approvalCount} approval(s).`); 27 | return approvalCount >= 2; 28 | - name: "Merge PR" 29 | if: ${{ steps.check.outputs.result == 'true' }} 30 | uses: actions/github-script@v6 31 | with: 32 | github-token: ${{ secrets.GITHUB_TOKEN }} 33 | script: | 34 | const pull_number = context.payload.pull_request.number; 35 | 36 | // PR 정보 및 병합 가능 상태 확인 37 | let pr; 38 | for (let i = 0; i < 5; i++) { // 최대 5회 재시도 39 | const { data } = await github.rest.pulls.get({ 40 | owner: context.repo.owner, 41 | repo: context.repo.repo, 42 | pull_number: pull_number, 43 | }); 44 | pr = data; 45 | 46 | if (pr.state !== 'open') { 47 | core.info(`PR #${pull_number} is not open (state: ${pr.state}).`); 48 | return; 49 | } 50 | 51 | if (pr.mergeable === true) { 52 | break; // 병합 가능하면 루프 종료 53 | } else if (pr.mergeable === false) { 54 | core.info(`PR #${pull_number} cannot be merged due to conflicts or other issues.`); 55 | return; 56 | } else { 57 | // mergeable이 null인 경우 (계산 중), 잠시 대기 후 재시도 58 | await new Promise(resolve => setTimeout(resolve, 2000)); // 2초 대기 59 | } 60 | } 61 | 62 | if (pr.mergeable !== true) { 63 | core.info(`PR #${pull_number} mergeable status is unknown after retries.`); 64 | return; 65 | } 66 | 67 | // PR 병합 시도 68 | try { 69 | await github.rest.pulls.merge({ 70 | owner: context.repo.owner, 71 | repo: context.repo.repo, 72 | pull_number: pull_number, 73 | }); 74 | core.info(`PR #${pull_number} has been merged successfully.`); 75 | } catch (error) { 76 | core.info(`Failed to merge PR #${pull_number}: ${error.message}`); 77 | } 78 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy on Server 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | deploy: 10 | name: Deploy on Server 11 | runs-on: [self-hosted, boost-was] # 라벨에 해당하는 runner로 실행 12 | 13 | steps: 14 | # 1. 레포지토리 클론 15 | - name: Checkout Repository 16 | uses: actions/checkout@v4 17 | 18 | # 2. Docker Compose로 서비스 빌드 및 재시작 19 | - name: Build and Deploy Docker Images 20 | env: 21 | NODE_ENV: production 22 | MONGO_URI: ${{ secrets.MONGO_URI }} 23 | JWT_SECRET: ${{ secrets.JWT_SECRET }} 24 | JWT_REFRESH_SECRET: ${{ secrets.JWT_REFRESH_SECRET }} 25 | VITE_API_URL: ${{ secrets.VITE_API_URL }} 26 | run: | 27 | docker-compose up -d --build 28 | 29 | # 3. Clean up Old Images 30 | - name: Remove Dangling Images 31 | run: docker image prune -f 32 | -------------------------------------------------------------------------------- /.github/workflows/lint_and_test.yml: -------------------------------------------------------------------------------- 1 | name: Lint and Test 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | - dev 8 | 9 | jobs: 10 | lint_and_unit_test: 11 | name: Lint and Unit Test 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | # Checkout the repository 16 | - name: Checkout repository 17 | uses: actions/checkout@v4 18 | 19 | # Install pnpm 20 | - name: Install pnpm 21 | uses: pnpm/action-setup@v4 22 | with: 23 | version: 9 24 | run_install: true 25 | 26 | # Set up Node.js 27 | - name: Set up Node.js 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version: "20" 31 | cache: "pnpm" 32 | 33 | # Run lint 34 | - name: Run Lint 35 | run: pnpm eslint . 36 | 37 | # Run Unit tests 38 | - name: Run Unit Tests 39 | run: pnpm test 40 | env: 41 | NODE_ENV: development 42 | JWT_SECRET: ${{ secrets.JWT_SECRET }} 43 | JWT_REFRESH_SECRET: ${{ secrets.JWT_REFRESH_SECRET }} 44 | VITE_API_URL: ${{ secrets.VITE_API_URL }} 45 | 46 | test_building_docker_image: 47 | name: Test Building Docker Image 48 | runs-on: ubuntu-latest 49 | 50 | steps: 51 | # Checkout the repository 52 | - name: Checkout Repository 53 | uses: actions/checkout@v4 54 | 55 | # Install Docker 56 | - name: Install Docker 57 | run: | 58 | sudo apt-get update 59 | sudo apt-get install -y apt-transport-https ca-certificates curl software-properties-common 60 | curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg 61 | echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null 62 | sudo apt-get update 63 | sudo apt-get install -y docker-ce docker-ce-cli containerd.io 64 | 65 | # Install Docker Compose 66 | - name: Install Docker Compose 67 | run: | 68 | sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose 69 | sudo chmod +x /usr/local/bin/docker-compose 70 | 71 | # Test Building Docker Images 72 | - name: Test Building Docker Images 73 | env: 74 | NODE_ENV: production 75 | MONGO_URI: ${{ secrets.MONGO_URI }} 76 | JWT_SECRET: ${{ secrets.JWT_SECRET }} 77 | JWT_REFRESH_SECRET: ${{ secrets.JWT_REFRESH_SECRET }} 78 | VITE_API_URL: ${{ secrets.VITE_API_URL }} 79 | run: | 80 | docker-compose build frontend backend 81 | -------------------------------------------------------------------------------- /.github/workflows/lint_and_unit_test.yml: -------------------------------------------------------------------------------- 1 | name: Lint and Unit Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - "**" 7 | 8 | jobs: 9 | lint_and_unit_test: 10 | name: Lint and Unit Test 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | # Checkout the repository 15 | - name: Checkout repository 16 | uses: actions/checkout@v4 17 | 18 | # Install pnpm 19 | - name: Install pnpm 20 | uses: pnpm/action-setup@v4 21 | with: 22 | version: 9 23 | run_install: true 24 | 25 | # Set up Node.js 26 | - name: Set up Node.js 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: "20" 30 | cache: "pnpm" 31 | 32 | # Run build 33 | - name: Install dependencies and build packages 34 | run: | 35 | pnpm install --frozen-lockfile 36 | pnpm --filter @noctaCrdt build 37 | pnpm --filter server build 38 | 39 | # Run lint 40 | - name: Run Lint 41 | run: pnpm eslint . 42 | 43 | # Run Unit tests 44 | - name: Run Unit Tests 45 | run: pnpm test 46 | env: 47 | NODE_ENV: development 48 | JWT_SECRET: ${{ secrets.JWT_SECRET }} 49 | JWT_REFRESH_SECRET: ${{ secrets.JWT_REFRESH_SECRET }} 50 | VITE_API_URL: ${{ secrets.VITE_API_URL }} 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | **/node_modules 3 | 4 | */dist 5 | /build 6 | .DS_Store 7 | .env 8 | 9 | tsconfig.tsbuildinfo 10 | 11 | # Jest globalConfig file 12 | ../globalConfig.json -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "printWidth": 100, 4 | "tabWidth": 2, 5 | "jsxSingleQuote": false, 6 | "singleQuote": false, 7 | 8 | "plugins": ["@pandabox/prettier-plugin"], 9 | "pandaFirstProps": ["as", "className", "layerStyle", "textStyle"], 10 | "pandaStylePropsFirst": true, 11 | "pandaSortOtherProps": true 12 | } 13 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "eslint.workingDirectories": [ 5 | { 6 | "pattern": "./packages/*/" 7 | } 8 | ], 9 | "eslint.useFlatConfig": true, 10 | "eslint.validate": ["javascript", "typescript", "javascriptreact", "html", "typescriptreact"], 11 | "eslint.enable": true, 12 | "editor.codeActionsOnSave": { 13 | "source.fixAll.eslint": "always" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /@noctaCrdt/NodeId.ts: -------------------------------------------------------------------------------- 1 | // NodeId.ts 2 | export abstract class NodeId { 3 | clock: number; 4 | client: number; 5 | 6 | constructor(clock: number, client: number) { 7 | this.clock = clock; 8 | this.client = client; 9 | } 10 | 11 | equals(other: NodeId): boolean { 12 | return this.clock === other.clock && this.client === other.client; 13 | } 14 | 15 | serialize(): any { 16 | return { 17 | clock: this.clock, 18 | client: this.client, 19 | }; 20 | } 21 | 22 | static deserialize(data: any): NodeId { 23 | throw new Error("Deserialize method should be implemented by subclasses"); 24 | } 25 | } 26 | 27 | export class BlockId extends NodeId { 28 | constructor(clock: number, client: number) { 29 | super(clock, client); 30 | } 31 | 32 | static deserialize(data: any): BlockId { 33 | return new BlockId(data.clock, data.client); 34 | } 35 | } 36 | 37 | export class CharId extends NodeId { 38 | constructor(clock: number, client: number) { 39 | super(clock, client); 40 | } 41 | 42 | static deserialize(data: any): CharId { 43 | return new CharId(data.clock, data.client); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /@noctaCrdt/Page.ts: -------------------------------------------------------------------------------- 1 | import { EditorCRDT } from "./Crdt"; 2 | import { Block } from "./Node"; 3 | import { CRDTSerializedProps, PageIconType } from "./Interfaces"; 4 | 5 | export interface PageSerializedProps { 6 | id: string; 7 | title: string; 8 | icon: PageIconType; 9 | crdt: CRDTSerializedProps; // EditorCRDT의 직렬화된 데이터 타입 10 | } 11 | 12 | export class Page { 13 | id: string; 14 | title: string; 15 | icon: PageIconType; 16 | crdt: EditorCRDT; 17 | 18 | constructor( 19 | id: string = crypto.randomUUID(), // 고유한 ID 생성 20 | title: string = "Untitled", 21 | icon: PageIconType = "Docs", 22 | editorCRDT: EditorCRDT = new EditorCRDT(0), 23 | ) { 24 | this.id = id; 25 | this.title = title; 26 | this.icon = icon; 27 | this.crdt = editorCRDT; 28 | } 29 | 30 | // 페이지 제목 업데이트 31 | updateTitle(newTitle: string): void { 32 | this.title = newTitle; 33 | } 34 | 35 | // 아이콘 업데이트 36 | updateIcon(newIcon: PageIconType): void { 37 | this.icon = newIcon; 38 | } 39 | 40 | // 직렬화 41 | serialize(): PageSerializedProps { 42 | return { 43 | id: this.id, 44 | title: this.title, 45 | icon: this.icon, 46 | crdt: this.crdt.serialize(), 47 | }; 48 | } 49 | 50 | // 역직렬화 51 | deserialize(data: PageSerializedProps): void { 52 | if (!data) { 53 | throw new Error("Invalid data for Page deserialization"); 54 | } 55 | 56 | try { 57 | this.id = data.id; 58 | this.title = data.title; 59 | this.icon = data.icon; 60 | 61 | // CRDT 역직렬화 62 | this.crdt.deserialize(data.crdt); 63 | } catch (error) { 64 | console.error("Error during Page deserialization:", error); 65 | throw new Error( 66 | `Failed to deserialize Page: ${error instanceof Error ? error.message : "Unknown error"}`, 67 | ); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /@noctaCrdt/WorkSpace.ts: -------------------------------------------------------------------------------- 1 | import { Page } from "./Page"; 2 | import { RemotePageUpdateOperation, WorkSpaceSerializedProps } from "./Interfaces"; 3 | import { EditorCRDT } from "./Crdt"; 4 | 5 | export class WorkSpace { 6 | id: string; 7 | name: string; 8 | pageList: Page[]; 9 | authUser: Map; 10 | 11 | constructor(id?: string, name?: string, pageList?: Page[], authUser?: Map) { 12 | this.id = id ? id : crypto.randomUUID(); 13 | this.name = name ? name : "Untitled"; 14 | this.pageList = pageList ? pageList : []; 15 | this.authUser = authUser ? authUser : new Map(); 16 | } 17 | 18 | serialize(): WorkSpaceSerializedProps { 19 | return { 20 | id: this.id, 21 | name: this.name, 22 | pageList: this.pageList, 23 | authUser: this.authUser, 24 | }; 25 | } 26 | 27 | deserialize(data: WorkSpaceSerializedProps): void { 28 | this.id = data.id; 29 | this.name = data.name; 30 | this.pageList = data.pageList.map((pageData) => { 31 | const page = new Page(); 32 | page.deserialize(pageData); 33 | return page; 34 | }); 35 | this.authUser = new Map(Object.entries(data.authUser)); 36 | } 37 | 38 | remotePageCreate(operation: { page: Page; workspaceId: string; clientId: number }): Page { 39 | const { page } = operation; 40 | const newEditorCRDT = new EditorCRDT(operation.clientId); 41 | const newPage = new Page(page.id, page.title, page.icon, newEditorCRDT); 42 | 43 | this.pageList.push(newPage); 44 | return newPage; 45 | } 46 | remotePageDelete(operation: { pageId: string; workspaceId: string; clientId: number }): void { 47 | const { pageId } = operation; 48 | 49 | // pageList에서 해당 페이지의 인덱스 찾기 50 | const pageIndex = this.pageList.findIndex((page) => page.id === pageId); 51 | 52 | // 페이지가 존재하면 삭제 53 | if (pageIndex !== -1) { 54 | this.pageList.splice(pageIndex, 1); 55 | } 56 | } 57 | 58 | remotePageUpdate(operation: RemotePageUpdateOperation): Page { 59 | const { pageId, title, icon } = operation; 60 | 61 | // pageList에서 해당 페이지 찾기 62 | const page = this.pageList.find((p) => p.id === pageId); 63 | 64 | // 페이지가 없으면 에러 발생 65 | if (!page) { 66 | throw new Error(`Page with id ${pageId} not found in workspace ${this.id}`); 67 | } 68 | 69 | // 전달받은 새로운 메타데이터로 페이지 정보 업데이트 70 | if (title !== undefined) { 71 | page.title = title; 72 | } 73 | 74 | if (icon !== undefined) { 75 | page.icon = icon; 76 | } 77 | 78 | return page; 79 | } 80 | getPage(data: string) { 81 | const page = this.pageList.find((page) => page.id === data); 82 | return page; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /@noctaCrdt/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@noctaCrdt", 3 | "version": "1.0.0", 4 | "main": "./dist/Crdt.js", 5 | "module": "./dist/Crdt.js", 6 | "types": "dist/Crdt.d.ts", 7 | "scripts": { 8 | "build": "tsc -b" 9 | }, 10 | "exports": { 11 | "./Crdt": { 12 | "require": "./dist/Crdt.js", 13 | "import": "./dist/Crdt.js", 14 | "types": "./dist/Crdt.d.js" 15 | }, 16 | "./Node": { 17 | "require": "./dist/Node.js", 18 | "import": "./dist/Node.js", 19 | "types": "./dist/Node.d.ts" 20 | }, 21 | "./LinkedList": { 22 | "require": "./dist/LinkedList.js", 23 | "import": "./dist/LinkedList.js", 24 | "types": "./dist/LinkedList.d.ts" 25 | }, 26 | "./Interfaces": { 27 | "require": "./dist/Interfaces.js", 28 | "import": "./dist/Interfaces.js", 29 | "types": "./dist/Interfaces.d.ts" 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /@noctaCrdt/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "./dist", 5 | "rootDir": ".", 6 | "composite": true, 7 | "declaration": true, 8 | "declarationMap": true, 9 | "sourceMap": true, 10 | "isolatedModules": true, 11 | "module": "CommonJS", 12 | "esModuleInterop": true, 13 | "allowJs": true, // 추가 14 | "moduleResolution": "node", // 추가 15 | "target": "ES2021" // 추가 16 | }, 17 | "include": ["**/*.ts"], 18 | "exclude": ["node_modules", "dist"] 19 | } 20 | -------------------------------------------------------------------------------- /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 | ## Panda 27 | styled-system 28 | styled-system-studio -------------------------------------------------------------------------------- /client/Dockerfile: -------------------------------------------------------------------------------- 1 | # 1. Node.js 20 기반 빌드 이미지 2 | FROM node:20 AS build 3 | WORKDIR /app 4 | 5 | # 2. 모노레포 루트에서 필요한 파일 복사 6 | COPY . . 7 | # COPY ./package.json ./pnpm-lock.yaml ./pnpm-workspace.yaml ./ 8 | # COPY ./client/package.json ./client/panda.config.ts ./client/vite.config.ts ./client/tsconfig.json ./client/ 9 | # COPY ./client/src/styles ./client/src/styles 10 | # COPY ./@noctaCrdt/package.json ./@noctaCrdt/ 11 | 12 | # 3. pnpm 설치 및 의존성 설치 13 | RUN npm install -g pnpm 14 | RUN pnpm install 15 | 16 | # 4. 애플리케이션 빌드 17 | RUN pnpm --filter client run build 18 | 19 | # 5. 빌드된 정적 파일 준비 (Nginx에서 제공) 20 | CMD ["echo", "Build completed. Use the Nginx container to serve the files."] -------------------------------------------------------------------------------- /client/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | # 1. Node.js 20 기반 이미지 2 | FROM node:20 3 | WORKDIR /app 4 | 5 | # 2. 모노레포 루트에서 필요한 파일 복사 6 | COPY ./package.json ./pnpm-lock.yaml ./pnpm-workspace.yaml ./ 7 | COPY ./client/package.json ./client/ 8 | 9 | # 3. pnpm 설치 및 의존성 설치 10 | RUN npm install -g pnpm 11 | RUN pnpm install 12 | 13 | # 4. 소스 코드 복사 14 | COPY . . 15 | 16 | # 5. 애플리케이션 포트 노출 17 | EXPOSE 5173 18 | 19 | # 6. Vite 개발 서버 실행 20 | CMD ["pnpm", "--filter", "client", "run", "dev"] -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | export default tseslint.config({ 18 | languageOptions: { 19 | // other options... 20 | parserOptions: { 21 | project: ['./tsconfig.node.json', './tsconfig.app.json'], 22 | tsconfigRootDir: import.meta.dirname, 23 | }, 24 | }, 25 | }); 26 | ``` 27 | 28 | - Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` 29 | - Optionally add `...tseslint.configs.stylisticTypeChecked` 30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: 31 | 32 | ```js 33 | // eslint.config.js 34 | import react from 'eslint-plugin-react'; 35 | 36 | export default tseslint.config({ 37 | // Set the react version 38 | settings: { react: { version: '18.3' } }, 39 | plugins: { 40 | // Add the react plugin 41 | react, 42 | }, 43 | rules: { 44 | // other rules... 45 | // Enable its recommended rules 46 | ...react.configs.recommended.rules, 47 | ...react.configs['jsx-runtime'].rules, 48 | }, 49 | }); 50 | ``` 51 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Nocta 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |
32 | 92 | ); 93 | }; 94 | -------------------------------------------------------------------------------- /client/src/components/sidebar/components/pageIconButton/PageIconButton.style.ts: -------------------------------------------------------------------------------- 1 | import { css } from "@styled-system/css"; 2 | 3 | export const IconBox = css({ 4 | display: "flex", 5 | position: "relative", 6 | justifyContent: "center", 7 | alignItems: "center", 8 | borderRadius: "4px", 9 | width: "24px", 10 | height: "24px", 11 | transition: "all 0.1s ease-in-out", 12 | cursor: "pointer", 13 | "&:hover": { 14 | transform: "translateY(-2px) scale(1.1)", 15 | }, 16 | }); 17 | 18 | export const IconModal = css({ 19 | zIndex: 1001, 20 | borderRadius: "4px", 21 | minWidth: "120px", // 3x3 그리드를 위한 최소 너비 22 | padding: "4px", 23 | backgroundColor: "white", 24 | boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)", 25 | }); 26 | 27 | export const IconModalContainer = css({ 28 | display: "grid", 29 | gap: "4px", 30 | gridTemplateColumns: "repeat(3, 1fr)", 31 | width: "100%", 32 | }); 33 | -------------------------------------------------------------------------------- /client/src/components/sidebar/components/pageIconButton/PageIconButton.tsx: -------------------------------------------------------------------------------- 1 | import { PageIconType } from "@noctaCrdt/Interfaces"; 2 | import { iconComponents, IconConfig } from "@constants/PageIconButton.config"; 3 | import { IconBox } from "./PageIconButton.style"; 4 | 5 | interface PageIconButtonProps { 6 | type: PageIconType; 7 | onClick: (e: React.MouseEvent) => void; 8 | } 9 | 10 | export const PageIconButton = ({ type, onClick }: PageIconButtonProps) => { 11 | const { icon: IconComponent, color: defaultColor }: IconConfig = iconComponents[type]; 12 | 13 | return ( 14 |
15 |
onClick(e)}> 16 | 17 |
18 |
19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /client/src/components/sidebar/components/pageIconButton/PageIconModal.tsx: -------------------------------------------------------------------------------- 1 | import { PageIconType } from "@noctaCrdt/Interfaces"; 2 | import { RiCloseLine } from "react-icons/ri"; 3 | import { iconGroups, iconComponents } from "@constants/PageIconButton.config"; 4 | import { css } from "@styled-system/css"; 5 | import { IconModal, IconModalContainer, IconModalClose, IconButton } from "./pageIconModal.style"; 6 | 7 | export interface PageIconModalProps { 8 | isOpen: boolean; 9 | onClose: (e: React.MouseEvent) => void; 10 | onSelect: (e: React.MouseEvent, type: PageIconType) => void; 11 | currentType: PageIconType; 12 | } 13 | 14 | export const PageIconModal = ({ onClose, onSelect, currentType }: PageIconModalProps) => { 15 | return ( 16 |
17 |
18 | 21 |
22 | {iconGroups.map((group) => ( 23 |
29 |
36 | {group.icons.map((iconType) => { 37 | const { icon: IconComponent, color } = iconComponents[iconType]; 38 | const isSelected = currentType === iconType; 39 | 40 | return ( 41 | 48 | ); 49 | })} 50 |
51 |
52 | ))} 53 |
54 |
55 |
56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /client/src/components/sidebar/components/pageIconButton/pageIconModal.style.ts: -------------------------------------------------------------------------------- 1 | import { css } from "@styled-system/css"; 2 | 3 | export const IconModal = css({ 4 | zIndex: 9000, 5 | position: "absolute", 6 | top: 14, 7 | left: 5, 8 | }); 9 | 10 | export const IconModalContainer = css({ 11 | zIndex: 1001, 12 | position: "relative", 13 | borderRadius: "4px", 14 | width: "100%", 15 | maxHeight: "80vh", 16 | padding: "16px 16px 0px 16px", 17 | backgroundColor: "white", 18 | boxShadow: "lg", 19 | overflowY: "auto", 20 | }); 21 | 22 | export const IconModalClose = css({ 23 | display: "flex", 24 | zIndex: 1002, 25 | position: "absolute", 26 | top: "-4px", 27 | right: "-4px", 28 | justifyContent: "center", 29 | alignItems: "center", 30 | border: "none", 31 | borderRadius: "md", 32 | width: "32px", 33 | height: "32px", 34 | opacity: 0.5, 35 | backgroundColor: "transparent", 36 | cursor: "pointer", 37 | _hover: { 38 | opacity: 1, 39 | }, 40 | }); 41 | 42 | export const IconName = css({ 43 | marginBottom: "8px", 44 | color: "gray.600", 45 | fontSize: "sm", 46 | fontWeight: "medium", 47 | }); 48 | 49 | export const IconButton = (isSelected: boolean) => 50 | css({ 51 | display: "flex", 52 | gap: "4px", 53 | flexDirection: "column", 54 | justifyContent: "space-between", 55 | alignItems: "center", 56 | border: "none", 57 | borderRadius: "4px", 58 | padding: "8px", 59 | backgroundColor: isSelected ? "rgba(220, 215, 255, 0.35)" : "transparent", 60 | transition: "all 0.1s ease-in-out", 61 | cursor: "pointer", 62 | _hover: { 63 | transform: "translateY(-2px) scale(1.1)", 64 | }, 65 | }); 66 | -------------------------------------------------------------------------------- /client/src/components/sidebar/components/pageItem/PageItem.style.ts: -------------------------------------------------------------------------------- 1 | import { css } from "@styled-system/css"; 2 | 3 | export const pageItemContainer = css({ 4 | display: "flex", 5 | position: "relative", 6 | gap: "lg", 7 | alignItems: "center", 8 | width: "100%", 9 | height: "56px", 10 | paddingInline: "md", 11 | "&:hover": { 12 | background: "white/50", 13 | cursor: "pointer", 14 | "& .delete_box": { 15 | visibility: "visible", 16 | opacity: 1, 17 | }, 18 | }, 19 | }); 20 | export const deleteBox = css({ 21 | display: "flex", 22 | visibility: "hidden", 23 | position: "absolute", 24 | right: "md", 25 | justifyContent: "center", 26 | alignItems: "center", 27 | borderRadius: "xs", 28 | width: "24px", 29 | height: "24px", 30 | opacity: 0, 31 | transition: "all 0.2s ease-in-out", 32 | cursor: "pointer", 33 | "&:hover": { 34 | background: "gray.100", 35 | }, 36 | }); 37 | export const iconBox = css({ 38 | display: "flex", 39 | flexShrink: 0, 40 | justifyContent: "center", 41 | alignItems: "center", 42 | borderRadius: "xs", 43 | width: "44px", 44 | height: "44px", 45 | fontSize: "24px", 46 | }); 47 | 48 | export const textBox = css({ 49 | textStyle: "display-medium20", 50 | color: "gray.700", 51 | textOverflow: "ellipsis", 52 | overflow: "hidden", 53 | whiteSpace: "nowrap", 54 | }); 55 | -------------------------------------------------------------------------------- /client/src/components/sidebar/components/pageItem/PageItem.tsx: -------------------------------------------------------------------------------- 1 | import { PageIconType } from "@noctaCrdt/Interfaces"; 2 | import { useEffect, useState } from "react"; 3 | import CloseIcon from "@assets/icons/close.svg?react"; 4 | import { useModal } from "@src/components/modal/useModal"; 5 | import { PageIconButton } from "../pageIconButton/PageIconButton"; 6 | import { PageIconModal } from "../pageIconButton/PageIconModal"; 7 | import { pageItemContainer, textBox, deleteBox } from "./PageItem.style"; 8 | 9 | interface PageItemProps { 10 | id: string; 11 | title: string; 12 | icon: PageIconType; 13 | onClick: () => void; 14 | onDelete?: (id: string) => void; // 추가: 삭제 핸들러 15 | handleIconUpdate: ( 16 | pageId: string, 17 | updates: { title?: string; icon?: PageIconType }, 18 | syncWithServer: boolean, 19 | ) => void; 20 | } 21 | 22 | export const PageItem = ({ 23 | id, 24 | icon, 25 | title, 26 | onClick, 27 | onDelete, 28 | handleIconUpdate, 29 | }: PageItemProps) => { 30 | const { isOpen, openModal, closeModal } = useModal(); 31 | const [pageIcon, setPageIcon] = useState(icon); 32 | 33 | useEffect(() => { 34 | setPageIcon(icon); 35 | }, [icon]); 36 | 37 | const handleDelete = (e: React.MouseEvent) => { 38 | e.stopPropagation(); // 상위 요소로의 이벤트 전파 중단 39 | onDelete?.(id); 40 | }; 41 | 42 | const handleToggleModal = (e: React.MouseEvent) => { 43 | e.stopPropagation(); 44 | if (isOpen) { 45 | closeModal(); 46 | } else { 47 | openModal(); 48 | } 49 | }; 50 | 51 | const handleCloseModal = (e: React.MouseEvent) => { 52 | closeModal(); 53 | e.stopPropagation(); 54 | }; 55 | 56 | const handleSelectIcon = (e: React.MouseEvent, type: PageIconType) => { 57 | e.stopPropagation(); 58 | setPageIcon(type); 59 | handleIconUpdate(id, { icon: type }, true); 60 | closeModal(); 61 | }; 62 | 63 | return ( 64 |
65 | 66 | {title || "새로운 페이지"} 67 | 68 | 69 | 70 | {isOpen && ( 71 | 77 | )} 78 |
79 | ); 80 | }; 81 | -------------------------------------------------------------------------------- /client/src/constants/PageIconButton.config.ts: -------------------------------------------------------------------------------- 1 | import { PageIconType } from "@noctaCrdt/Interfaces"; 2 | import { IconType } from "react-icons"; 3 | import { CgGym } from "react-icons/cg"; 4 | import { MdOutlinePlace } from "react-icons/md"; 5 | import { 6 | // 기본 문서 타입 7 | RiFileTextLine, // docs: 일반 문서 8 | RiStickyNoteLine, // note: 필기/메모 9 | RiBookReadLine, // wiki: 지식 베이스 10 | 11 | // 업무 관련 12 | RiProjectorLine, // project: 프로젝트 13 | RiTeamLine, // meeting: 회의록 14 | RiTaskLine, // task: 할일/작업 15 | 16 | // 개인 활동 17 | RiBookMarkedLine, // diary: 일기/저널 18 | RiQuillPenLine, // blog: 블로그 19 | 20 | // 학습 관련 21 | RiBookOpenLine, // study: 학습 22 | RiSearchLine, // research: 연구/조사 23 | RiBookmarkLine, 24 | 25 | // 협업 관련 26 | RiGroupLine, // team: 팀 문서 27 | RiDiscussLine, // feedback: 피드백 28 | RiAddFill, // plus: 추가 29 | } from "react-icons/ri"; 30 | 31 | export interface IconConfig { 32 | icon: IconType; 33 | color: string; 34 | } 35 | 36 | export const iconComponents: Record = { 37 | // 기본 문서 타입 38 | Docs: { 39 | icon: RiFileTextLine, 40 | color: "#2B4158", 41 | }, 42 | Note: { 43 | icon: RiStickyNoteLine, 44 | color: "#FEA642", 45 | }, 46 | Wiki: { 47 | icon: RiBookReadLine, 48 | color: "#A142FE", 49 | }, 50 | 51 | // 업무 관련 52 | Project: { 53 | icon: RiProjectorLine, 54 | color: "#1BBF44", 55 | }, 56 | Meeting: { 57 | icon: RiTeamLine, 58 | color: "#4E637C", 59 | }, 60 | Task: { 61 | icon: RiTaskLine, 62 | color: "#F24150", 63 | }, 64 | 65 | // 개인 활동 66 | Diary: { 67 | icon: RiBookMarkedLine, 68 | color: "#FF69B4", 69 | }, 70 | Blog: { 71 | icon: RiQuillPenLine, 72 | color: "#99AFCA", 73 | }, 74 | Entertain: { 75 | icon: CgGym, 76 | color: "#9ACD32", 77 | }, 78 | 79 | // 학습 관련 80 | Study: { 81 | icon: RiBookOpenLine, 82 | color: "#4285F4", 83 | }, 84 | Research: { 85 | icon: RiSearchLine, 86 | color: "#2B4158", 87 | }, 88 | Book: { 89 | icon: RiBookmarkLine, 90 | color: "#8B4513", 91 | }, 92 | 93 | // 협업 관련 94 | Team: { 95 | icon: RiGroupLine, 96 | color: "#FF8C00", 97 | }, 98 | Shared: { 99 | icon: MdOutlinePlace, 100 | color: "#4285F4", 101 | }, 102 | Feedback: { 103 | icon: RiDiscussLine, 104 | color: "#A142FE", 105 | }, 106 | 107 | plus: { 108 | icon: RiAddFill, 109 | color: "#2B4158", 110 | }, 111 | }; 112 | 113 | export const iconGroups = [ 114 | { 115 | title: "기본 문서", 116 | icons: ["Docs", "Note", "Wiki"] as PageIconType[], 117 | }, 118 | { 119 | title: "업무 관련", 120 | icons: ["Project", "Meeting", "Task"] as PageIconType[], 121 | }, 122 | { 123 | title: "개인 활동", 124 | icons: ["Diary", "Blog", "Entertain"] as PageIconType[], 125 | }, 126 | { 127 | title: "학습 관련", 128 | icons: ["Study", "Research", "Book"] as PageIconType[], 129 | }, 130 | { 131 | title: "협업 관련", 132 | icons: ["Team", "Shared", "Feedback"] as PageIconType[], 133 | }, 134 | ]; 135 | -------------------------------------------------------------------------------- /client/src/constants/color.ts: -------------------------------------------------------------------------------- 1 | export const COLOR = { 2 | WHITE: "#FFFFFF", 3 | GRAY_100: "#C1D7F4", 4 | GRAY_300: "#99AFCA", 5 | GRAY_500: "#7388A2", 6 | GRAY_700: "#4E637C", 7 | GRAY_900: "#2B4158", 8 | SHADOW: "#004585", 9 | RED: "#E25D68", 10 | YELLOW: "#EEAF66", 11 | GREEN: "#3BC05C", 12 | PURPLE: "#9862CD", 13 | BROWN: "#985728", 14 | BLUE: "#6293E5", 15 | }; 16 | -------------------------------------------------------------------------------- /client/src/constants/option.ts: -------------------------------------------------------------------------------- 1 | import { AnimationType, ElementType, TextStyleType } from "@noctaCrdt/Interfaces"; 2 | 3 | export const OPTION_CATEGORIES = { 4 | TYPE: { 5 | id: "type", 6 | label: "전환", 7 | options: [ 8 | { id: "p", label: "기본" }, 9 | { id: "h1", label: "제목 1" }, 10 | { id: "h2", label: "제목 2" }, 11 | { id: "h3", label: "제목 3" }, 12 | { id: "ul", label: "리스트" }, 13 | { id: "ol", label: "순서 리스트" }, 14 | { id: "checkbox", label: "체크박스" }, 15 | { id: "blockquote", label: "인용문" }, 16 | { id: "hr", label: "구분선" }, 17 | ] as { id: ElementType; label: string }[], 18 | }, 19 | ANIMATION: { 20 | id: "animation", 21 | label: "애니메이션", 22 | options: [ 23 | { id: "none", label: "없음" }, 24 | { id: "highlight", label: "하이라이트" }, 25 | { id: "rainbow", label: "레인보우" }, 26 | { id: "gradation", label: "그라데이션" }, 27 | { id: "fadeIn", label: "페이드 인" }, 28 | { id: "slideIn", label: "슬라이드 인" }, 29 | { id: "pulse", label: "펄스" }, 30 | { id: "bounce", label: "바운스" }, 31 | ] as { id: AnimationType; label: string }[], 32 | }, 33 | DUPLICATE: { 34 | id: "duplicate", 35 | label: "복제", 36 | options: null, 37 | }, 38 | DELETE: { 39 | id: "delete", 40 | label: "삭제", 41 | options: null, 42 | }, 43 | }; 44 | 45 | export const TEXT_OPTION_CATEGORIES = { 46 | TYPE: { 47 | id: "textType", 48 | label: "글자", 49 | options: [ 50 | { id: "bold", label: "굵게" }, 51 | { id: "italic", label: "기울임" }, 52 | { id: "underline", label: "밑줄" }, 53 | { id: "strikethrough", label: "취소선" }, 54 | ] as { id: TextStyleType; label: string }[], 55 | }, 56 | }; 57 | 58 | export type OptionCategory = keyof typeof OPTION_CATEGORIES; 59 | export type TextOptionCategory = keyof typeof TEXT_OPTION_CATEGORIES; 60 | -------------------------------------------------------------------------------- /client/src/constants/page.ts: -------------------------------------------------------------------------------- 1 | export const MAX_VISIBLE_PAGE = 10; 2 | -------------------------------------------------------------------------------- /client/src/constants/size.ts: -------------------------------------------------------------------------------- 1 | export const PAGE = { 2 | WIDTH: 500, 3 | HEIGHT: 400, 4 | 5 | MIN_WIDTH: 300, 6 | MIN_HEIGHT: 200, 7 | }; 8 | 9 | export const SIDE_BAR = { 10 | MIN_WIDTH: 40, 11 | WIDTH: 300, 12 | }; 13 | -------------------------------------------------------------------------------- /client/src/constants/spacing.ts: -------------------------------------------------------------------------------- 1 | export const SPACING = { 2 | SMALL: 16, 3 | MEDIUM: 20, 4 | LARGE: 24, 5 | }; 6 | -------------------------------------------------------------------------------- /client/src/features/auth/AuthButton.style.ts: -------------------------------------------------------------------------------- 1 | import { css } from "@styled-system/css"; 2 | 3 | export const container = css({ 4 | display: "flex", 5 | gap: "md", 6 | flexGrow: 1, 7 | justifyContent: "end", 8 | alignItems: "center", 9 | width: "auto", 10 | }); 11 | -------------------------------------------------------------------------------- /client/src/features/auth/AuthButton.tsx: -------------------------------------------------------------------------------- 1 | import { useLogoutMutation } from "@apis/auth"; 2 | import { TextButton } from "@components/button/textButton"; 3 | import { Modal } from "@components/modal/modal"; 4 | import { useModal } from "@components/modal/useModal"; 5 | import { useCheckLogin } from "@stores/useUserStore"; 6 | import { container } from "./AuthButton.style"; 7 | import { AuthModal } from "./AuthModal"; 8 | 9 | export const AuthButton = () => { 10 | const isLogin = useCheckLogin(); 11 | 12 | const { 13 | isOpen: isAuthModalOpen, 14 | openModal: openAuthModal, 15 | closeModal: closeAuthModal, 16 | } = useModal(); 17 | 18 | const { 19 | isOpen: isLogoutModalOpen, 20 | openModal: openLogoutModal, 21 | closeModal: closeLogoutModal, 22 | } = useModal(); 23 | 24 | const { mutate: logout } = useLogoutMutation(closeLogoutModal); 25 | 26 | return ( 27 |
28 | {isLogin ? ( 29 | 30 | 로그아웃 31 | 32 | ) : ( 33 | 34 | 로그인 35 | 36 | )} 37 | 38 | 39 | 46 |

로그아웃 하시겠습니까?

47 |
48 |
49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /client/src/features/auth/AuthModal.style.ts: -------------------------------------------------------------------------------- 1 | import { css } from "@styled-system/css"; 2 | 3 | export const container = css({ 4 | display: "flex", 5 | gap: "lg", 6 | flexDirection: "column", 7 | justifyContent: "space-between", 8 | width: "100%", 9 | height: "360px", 10 | }); 11 | 12 | export const title = css({ 13 | textStyle: "display-medium32", 14 | color: "white", 15 | textAlign: "center", 16 | textShadow: "0 2px 4px rgba(0,0,0,0.1)", 17 | }); 18 | 19 | export const errorWrapper = css({ 20 | display: "flex", 21 | justifyContent: "center", 22 | width: "100%", 23 | height: "20px", 24 | paddingBottom: "40px", 25 | }); 26 | export const toggleButton = css({ 27 | marginBottom: "md", 28 | color: "white", 29 | cursor: "pointer", 30 | "&:hover": { 31 | textDecoration: "underline", 32 | }, 33 | }); 34 | export const errorContainer = css({ 35 | display: "flex", 36 | position: "relative", 37 | alignContent: "center", 38 | alignItems: "center", 39 | color: "red", 40 | }); 41 | export const formContainer = css({ 42 | display: "flex", 43 | gap: "md", 44 | flexDirection: "column", 45 | }); 46 | -------------------------------------------------------------------------------- /client/src/features/editor/Editor.style.ts: -------------------------------------------------------------------------------- 1 | import { css } from "@styled-system/css"; 2 | 3 | export const editorContainer = css({ 4 | width: "full", 5 | height: "full", // 부모 컴포넌트의 header(60px)를 제외한 높이 6 | margin: "spacing.lg", // 16px margin 7 | padding: "24px", // 24px padding 8 | overflowX: "hidden", 9 | overflowY: "auto", // 내용이 많을 경우 스크롤 10 | _focus: { 11 | outline: "none", 12 | }, 13 | }); 14 | 15 | export const editorTitleContainer = css({ 16 | display: "flex", 17 | gap: "4px", 18 | flexDirection: "column", 19 | width: "full", 20 | padding: "spacing.sm", 21 | }); 22 | 23 | export const editorTitle = css({ 24 | textStyle: "display-medium28", 25 | outline: "none", 26 | border: "none", 27 | width: "full", 28 | paddingLeft: "7.5px", 29 | color: "gray.700", 30 | "&::placeholder": { 31 | color: "gray.100", 32 | }, 33 | }); 34 | 35 | export const checkboxContainer = css({ 36 | display: "flex", 37 | gap: "spacing.sm", 38 | flexDirection: "row", 39 | alignItems: "center", 40 | }); 41 | 42 | export const checkbox = css({ 43 | border: "1px solid", 44 | borderColor: "gray.300", 45 | borderRadius: "4px", 46 | width: "16px", 47 | height: "16px", 48 | margin: "0 8px 0 0", 49 | cursor: "pointer", 50 | "&:checked": { 51 | borderColor: "blue.500", 52 | backgroundColor: "blue.500", 53 | }, 54 | }); 55 | 56 | export const addNewBlockButton = css({ 57 | display: "flex", 58 | gap: "spacing.sm", 59 | borderRadius: "4px", 60 | padding: "spacing.sm", 61 | color: "gray.900", 62 | opacity: 0.8, 63 | cursor: "pointer", 64 | "&:hover": { 65 | opacity: 1, 66 | }, 67 | }); 68 | -------------------------------------------------------------------------------- /client/src/features/editor/components/ColorOptionModal/BackgroundColorOptionModal.style.ts: -------------------------------------------------------------------------------- 1 | import { BackgroundColorType } from "@noctaCrdt/Interfaces"; 2 | import { css, cva } from "@styled-system/css"; 3 | 4 | type ColorVariants = { 5 | [K in BackgroundColorType]: { backgroundColor: string }; 6 | }; 7 | 8 | export const colorPaletteModal = css({ 9 | zIndex: 1001, 10 | borderRadius: "4px", 11 | minWidth: "120px", // 3x3 그리드를 위한 최소 너비 12 | padding: "4px", 13 | backgroundColor: "white", 14 | boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)", 15 | }); 16 | 17 | export const colorPaletteContainer = css({ 18 | display: "grid", 19 | gap: "4px", 20 | gridTemplateColumns: "repeat(3, 1fr)", 21 | width: "100%", 22 | }); 23 | 24 | export const colorOptionButton = css({ 25 | display: "flex", 26 | justifyContent: "center", 27 | alignItems: "center", 28 | border: "none", 29 | borderRadius: "4px", 30 | width: "28px", 31 | height: "28px", 32 | margin: "0 2px", 33 | padding: "2px", 34 | transition: "transform 0.2s", 35 | cursor: "pointer", 36 | "&:hover": { 37 | transform: "scale(1.1)", 38 | }, 39 | }); 40 | 41 | const colorVariants: ColorVariants = { 42 | transparent: { backgroundColor: "#C1D7F4" }, 43 | black: { backgroundColor: "#2B4158" }, 44 | red: { backgroundColor: "#E25D68" }, 45 | green: { backgroundColor: "#3BC05C" }, 46 | blue: { backgroundColor: "#6293E5" }, 47 | yellow: { backgroundColor: "#EEAF66" }, 48 | purple: { backgroundColor: "#9862CD" }, 49 | brown: { backgroundColor: "#985728" }, 50 | white: { backgroundColor: "#FFFFFF" }, 51 | }; 52 | 53 | export const backgroundColorIndicator = cva({ 54 | base: { 55 | border: "0.5px solid gray", 56 | borderRadius: "3px", 57 | width: "100%", 58 | height: "100%", 59 | transition: "all 0.2s", 60 | }, 61 | variants: { 62 | color: colorVariants, 63 | }, 64 | }); 65 | -------------------------------------------------------------------------------- /client/src/features/editor/components/ColorOptionModal/BackgroundColorOptionModal.tsx: -------------------------------------------------------------------------------- 1 | import { BackgroundColorType } from "@noctaCrdt/Interfaces"; 2 | import { 3 | backgroundColorIndicator, 4 | colorOptionButton, 5 | colorPaletteContainer, 6 | colorPaletteModal, 7 | } from "./BackgroundColorOptionModal.style.ts"; 8 | 9 | const COLORS: BackgroundColorType[] = [ 10 | "black", 11 | "red", 12 | "blue", 13 | "green", 14 | "yellow", 15 | "purple", 16 | "brown", 17 | "white", 18 | "transparent", 19 | ]; 20 | 21 | interface BackgroundColorOptionModalProps { 22 | onColorSelect: (color: BackgroundColorType) => void; 23 | position: { top: number; left: number }; 24 | } 25 | 26 | export const BackgroundColorOptionModal = ({ 27 | onColorSelect, 28 | position, 29 | }: BackgroundColorOptionModalProps) => { 30 | return ( 31 |
39 |
40 | {COLORS.map((color) => ( 41 | 48 | ))} 49 |
50 |
51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /client/src/features/editor/components/ColorOptionModal/TextColorOptionModal.style.ts: -------------------------------------------------------------------------------- 1 | import { TextColorType } from "@noctaCrdt/Interfaces"; 2 | import { css, cva } from "@styled-system/css"; 3 | 4 | type ColorVariants = { 5 | [K in TextColorType]: { color: string }; 6 | }; 7 | 8 | export const colorPaletteModal = css({ 9 | zIndex: 1001, 10 | borderRadius: "4px", 11 | minWidth: "120px", // 3x3 그리드를 위한 최소 너비 12 | padding: "4px", 13 | backgroundColor: "white", 14 | boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)", 15 | }); 16 | 17 | export const colorPaletteContainer = css({ 18 | display: "grid", 19 | gap: "4px", 20 | gridTemplateColumns: "repeat(3, 1fr)", 21 | width: "100%", 22 | }); 23 | 24 | export const colorOptionButton = css({ 25 | display: "flex", 26 | justifyContent: "center", 27 | alignItems: "center", 28 | border: "none", 29 | borderRadius: "4px", 30 | width: "28px", 31 | height: "28px", 32 | margin: "0 2px", 33 | padding: "2px", 34 | transition: "transform 0.2s", 35 | cursor: "pointer", 36 | "&:hover": { 37 | transform: "scale(1.1)", 38 | }, 39 | }); 40 | 41 | const colorVariants: ColorVariants = { 42 | black: { color: "#2B4158" }, 43 | red: { color: "#E25D68" }, 44 | green: { color: "#3BC05C" }, 45 | blue: { color: "#6293E5" }, 46 | yellow: { color: "#EEAF66" }, 47 | purple: { color: "#9862CD" }, 48 | brown: { color: "#985728" }, 49 | white: { color: "#FFFFFF" }, 50 | }; 51 | 52 | export const textColorIndicator = cva({ 53 | base: { 54 | display: "flex", 55 | justifyContent: "center", 56 | alignItems: "center", 57 | width: "100%", 58 | height: "100%", 59 | textShadow: "0.5px 0 black", 60 | fontSize: "16px", 61 | fontWeight: "bold", 62 | }, 63 | variants: { 64 | color: colorVariants, 65 | }, 66 | }); 67 | -------------------------------------------------------------------------------- /client/src/features/editor/components/ColorOptionModal/TextColorOptionModal.tsx: -------------------------------------------------------------------------------- 1 | import { TextColorType } from "@noctaCrdt/Interfaces"; 2 | import { 3 | colorOptionButton, 4 | colorPaletteContainer, 5 | colorPaletteModal, 6 | textColorIndicator, 7 | } from "./TextColorOptionModal.style.ts"; 8 | 9 | const COLORS: TextColorType[] = [ 10 | "black", 11 | "red", 12 | "blue", 13 | "green", 14 | "yellow", 15 | "purple", 16 | "brown", 17 | "white", 18 | ]; 19 | 20 | interface TextColorOptionModalProps { 21 | onColorSelect: (color: TextColorType) => void; 22 | position: { top: number; left: number }; 23 | } 24 | 25 | export const TextColorOptionModal = ({ onColorSelect, position }: TextColorOptionModalProps) => { 26 | return ( 27 |
35 |
36 | {COLORS.map((color) => ( 37 | 44 | ))} 45 |
46 |
47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /client/src/features/editor/components/IconBlock/IconBlock.style.ts: -------------------------------------------------------------------------------- 1 | // IconBlock.style.ts 2 | import { css, cva } from "@styled-system/css"; 3 | 4 | export const iconContainerStyle = css({ 5 | display: "flex", 6 | justifyContent: "center", 7 | width: "24px", 8 | height: "24px", 9 | marginRight: "8px", 10 | }); 11 | 12 | export const iconStyle = cva({ 13 | base: { 14 | display: "flex", 15 | justifyContent: "center", 16 | alignItems: "center", 17 | color: "gray.600", 18 | fontSize: "14px", 19 | }, 20 | variants: { 21 | type: { 22 | ul: { 23 | fontSize: "6px", // bullet point size 24 | }, 25 | ol: { 26 | paddingRight: "4px", 27 | }, 28 | checkbox: { 29 | borderRadius: "2px", 30 | width: "16px", 31 | height: "16px", 32 | backgroundColor: "white", 33 | }, 34 | }, 35 | isChecked: { 36 | true: { 37 | color: "white", 38 | backgroundColor: "#7272FF", 39 | }, 40 | false: { 41 | color: "gray.600", 42 | backgroundColor: "white", 43 | }, 44 | }, 45 | }, 46 | }); 47 | -------------------------------------------------------------------------------- /client/src/features/editor/components/IconBlock/IconBlock.tsx: -------------------------------------------------------------------------------- 1 | import { ElementType } from "@noctaCrdt/Interfaces"; 2 | import { iconContainerStyle, iconStyle } from "./IconBlock.style"; 3 | 4 | interface IconBlockProps { 5 | type: ElementType; 6 | index: number | undefined; 7 | indent?: number; 8 | isChecked?: boolean; 9 | onCheckboxClick?: () => void; 10 | } 11 | 12 | export const IconBlock = ({ 13 | type, 14 | index = 1, 15 | indent = 0, 16 | isChecked = false, 17 | onCheckboxClick, 18 | }: IconBlockProps) => { 19 | const getIcon = () => { 20 | switch (type) { 21 | case "ul": 22 | return ( 23 | 24 | {indent === 0 && "●"} 25 | {indent === 1 && "○"} 26 | {indent === 2 && "■"} 27 | 28 | ); 29 | case "ol": 30 | return {`${index}.`}; 31 | case "checkbox": 32 | return ( 33 | 40 | {isChecked ? "✓" : ""} 41 | 42 | ); 43 | default: 44 | return null; 45 | } 46 | }; 47 | 48 | const icon = getIcon(); 49 | if (!icon) return null; 50 | 51 | return
{icon}
; 52 | }; 53 | -------------------------------------------------------------------------------- /client/src/features/editor/components/MenuBlock/MenuBlock.style.ts: -------------------------------------------------------------------------------- 1 | import { css } from "@styled-system/css"; 2 | 3 | export const menuBlockStyle = css({ 4 | display: "flex", 5 | zIndex: 1, 6 | position: "absolute", 7 | top: 0, 8 | left: 0, 9 | justifyContent: "center", 10 | alignItems: "center", 11 | width: "24px", 12 | height: "24px", 13 | marginLeft: "-20px", 14 | opacity: 0, 15 | transition: "opacity 0.2s ease-in-out", 16 | cursor: "grab", 17 | _active: { 18 | cursor: "grabbing", 19 | }, 20 | }); 21 | 22 | export const dragHandleIconStyle = css({ 23 | display: "flex", 24 | justifyContent: "center", 25 | alignItems: "center", 26 | width: "100%", 27 | height: "100%", 28 | }); 29 | -------------------------------------------------------------------------------- /client/src/features/editor/components/MenuBlock/MenuBlock.tsx: -------------------------------------------------------------------------------- 1 | import { AnimationType, ElementType } from "@noctaCrdt/Interfaces"; 2 | import { useState, useRef } from "react"; 3 | import DraggableIcon from "@assets/icons/draggable.svg?url"; 4 | import { useModal } from "@src/components/modal/useModal"; 5 | import { OptionModal } from "../OptionModal/OptionModal"; 6 | import { menuBlockStyle, dragHandleIconStyle } from "./MenuBlock.style"; 7 | 8 | export interface MenuBlockProps { 9 | attributes?: Record; 10 | listeners?: Record; 11 | onAnimationSelect: (animation: AnimationType) => void; 12 | onTypeSelect: (type: ElementType) => void; 13 | onCopySelect: () => void; 14 | onDeleteSelect: () => void; 15 | } 16 | 17 | export const MenuBlock = ({ 18 | attributes, 19 | listeners, 20 | onAnimationSelect, 21 | onTypeSelect, 22 | onCopySelect, 23 | onDeleteSelect, 24 | }: MenuBlockProps) => { 25 | const menuBlockRef = useRef(null); 26 | 27 | const [pressTime, setPressTime] = useState(null); 28 | const [isDragging, setIsDragging] = useState(false); 29 | const [menuBlockPosition, setMenuBlockPosition] = useState<{ top: number; right: number }>({ 30 | top: 0, 31 | right: 0, 32 | }); 33 | 34 | const { isOpen, openModal, closeModal } = useModal(); 35 | 36 | const handlePressStart = () => { 37 | const timer = setTimeout(() => { 38 | setIsDragging(true); 39 | }, 300); 40 | 41 | setPressTime(timer); 42 | }; 43 | 44 | const handlePressEnd = () => { 45 | if (pressTime) { 46 | clearTimeout(pressTime); 47 | setPressTime(null); 48 | } 49 | 50 | if (!isDragging) { 51 | if (menuBlockRef.current) { 52 | const { top, right } = menuBlockRef.current.getBoundingClientRect(); 53 | setMenuBlockPosition({ top, right }); 54 | } 55 | openModal(); 56 | } 57 | setIsDragging(false); 58 | }; 59 | 60 | const modifiedListeners = { 61 | ...listeners, 62 | // dnd 이벤트 덮어쓰기 63 | onMouseDown: (e: React.MouseEvent) => { 64 | handlePressStart(); 65 | listeners?.onMouseDown?.(e); 66 | }, 67 | onMouseUp: (e: React.MouseEvent) => { 68 | handlePressEnd(); 69 | listeners?.onMouseUp?.(e); 70 | }, 71 | }; 72 | 73 | return ( 74 |
80 |
81 | drag handle 82 |
83 | 92 |
93 | ); 94 | }; 95 | -------------------------------------------------------------------------------- /client/src/features/editor/components/OptionModal/OptionModal.animaiton.ts: -------------------------------------------------------------------------------- 1 | export const modal = { 2 | initial: { 3 | opacity: 0, 4 | x: -5, 5 | }, 6 | animate: { 7 | opacity: 1, 8 | x: 0, 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /client/src/features/editor/components/OptionModal/OptionModal.style.ts: -------------------------------------------------------------------------------- 1 | import { css } from "@styled-system/css"; 2 | 3 | export const optionModal = css({ 4 | zIndex: "10000", 5 | position: "fixed", 6 | borderRadius: "8px", 7 | width: "160px", 8 | padding: "8px", 9 | background: "white", 10 | boxShadow: "md", 11 | }); 12 | 13 | export const optionButton = css({ 14 | borderRadius: "8px", 15 | width: "100%", 16 | paddingBlock: "4px", 17 | paddingInline: "8px", 18 | textAlign: "left", 19 | _hover: { 20 | backgroundColor: "gray.100/40", 21 | cursor: "pointer", 22 | }, 23 | }); 24 | 25 | export const optionTypeButton = css({ 26 | borderRadius: "8px", 27 | width: "100%", 28 | paddingBlock: "4px", 29 | paddingInline: "8px", 30 | textAlign: "left", 31 | "&.selected": { 32 | backgroundColor: "gray.100/40", 33 | }, 34 | }); 35 | 36 | export const modalContainer = css({ 37 | display: "flex", 38 | gap: "1", 39 | flexDirection: "column", 40 | }); 41 | -------------------------------------------------------------------------------- /client/src/features/editor/components/TextOptionModal/TextOptionModal.style.ts: -------------------------------------------------------------------------------- 1 | import { css } from "@styled-system/css"; 2 | 3 | export const optionModal = css({ 4 | zIndex: 1000, 5 | position: "fixed", 6 | borderRadius: "4px", 7 | background: "white", 8 | boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)", 9 | }); 10 | 11 | export const modalContainer = css({ 12 | display: "flex", 13 | gap: "4px", 14 | padding: "8px", 15 | }); 16 | 17 | export const optionButton = css({ 18 | display: "flex", 19 | position: "relative", 20 | justifyContent: "center", 21 | alignItems: "center", 22 | border: "none", 23 | borderRadius: "4px", 24 | width: "28px", 25 | height: "28px", 26 | padding: "4px 8px", 27 | cursor: "pointer", 28 | }); 29 | 30 | export const divider = css({ 31 | width: "1px", 32 | height: "20px", 33 | margin: "0 8px", 34 | }); 35 | 36 | export const optionButtonText = css({ 37 | color: "transparent", 38 | fontWeight: "bold", 39 | opacity: 0.9, 40 | backgroundPosition: "0% 50%", 41 | backgroundClip: "text", 42 | backgroundImage: "linear-gradient(45deg, #2563EB 0%, #7E22CE 50%, #FF0080 100%)", 43 | backgroundSize: "200% 200%", 44 | transition: "all 0.2s ease", 45 | WebkitTextFillColor: "transparent", 46 | _hover: { 47 | backgroundPosition: "100% -10%", 48 | }, 49 | }); 50 | -------------------------------------------------------------------------------- /client/src/features/editor/components/TypeOptionModal/TypeOptionModal.tsx: -------------------------------------------------------------------------------- 1 | import { motion } from "framer-motion"; 2 | import { ElementType } from "node_modules/@noctaCrdt/Interfaces"; 3 | import { useEffect, useRef, useState } from "react"; 4 | import { createPortal } from "react-dom"; 5 | import { OPTION_CATEGORIES } from "@src/constants/option"; 6 | import { modal } from "../OptionModal/OptionModal.animaiton"; 7 | import { modalContainer, optionModal, optionTypeButton } from "../OptionModal/OptionModal.style"; 8 | 9 | interface TypeOptionModalProps { 10 | isOpen: boolean; 11 | onClose: () => void; 12 | onTypeSelect: (type: ElementType) => void; 13 | position: { top: number; left: number }; 14 | } 15 | 16 | export const TypeOptionModal = ({ 17 | isOpen, 18 | onClose, 19 | onTypeSelect, 20 | position, 21 | }: TypeOptionModalProps) => { 22 | const modalRef = useRef(null); 23 | const [selectedIndex, setSelectedIndex] = useState(0); 24 | const { 25 | TYPE: { options }, 26 | } = OPTION_CATEGORIES; 27 | 28 | const handleKeyDown = (e: React.KeyboardEvent) => { 29 | switch (e.key) { 30 | case "ArrowUp": 31 | e.preventDefault(); 32 | setSelectedIndex((prev) => (prev <= 0 ? options.length - 1 : prev - 1)); 33 | break; 34 | 35 | case "ArrowDown": 36 | e.preventDefault(); 37 | setSelectedIndex((prev) => (prev >= options.length - 1 ? 0 : prev + 1)); 38 | break; 39 | 40 | case "Enter": 41 | e.preventDefault(); 42 | onTypeSelect(options[selectedIndex].id); 43 | onClose(); 44 | break; 45 | 46 | case "Escape": 47 | e.preventDefault(); 48 | onClose(); 49 | break; 50 | } 51 | }; 52 | 53 | useEffect(() => { 54 | if (isOpen && modalRef.current) { 55 | modalRef.current.focus(); 56 | } 57 | }, [isOpen]); 58 | 59 | if (!isOpen) return null; 60 | 61 | return createPortal( 62 | <> 63 |
75 | 89 |
90 | {options.map((option, index) => ( 91 | 102 | ))} 103 |
104 |
105 | , 106 | document.body, 107 | ); 108 | }; 109 | -------------------------------------------------------------------------------- /client/src/features/editor/hooks/useBlockAnimtaion.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export const useBlockAnimation = (blockRef: React.RefObject) => { 4 | const [isAnimationStart, setIsAnimationStart] = useState(false); 5 | 6 | useEffect(() => { 7 | const observer = new IntersectionObserver( 8 | (entries) => { 9 | entries.forEach((entry) => { 10 | if (entry.isIntersecting) { 11 | setIsAnimationStart(true); 12 | observer.unobserve(entry.target); 13 | } 14 | }); 15 | }, 16 | { 17 | threshold: 0.1, 18 | }, 19 | ); 20 | 21 | if (blockRef.current) { 22 | observer.observe(blockRef.current); 23 | } 24 | 25 | return () => { 26 | if (blockRef.current) { 27 | observer.unobserve(blockRef.current); 28 | } 29 | }; 30 | }, []); 31 | 32 | return { isAnimationStart }; 33 | }; 34 | -------------------------------------------------------------------------------- /client/src/features/editor/utils/markdownPatterns.ts: -------------------------------------------------------------------------------- 1 | import { MarkdownElement, MarkdownPattern } from "../../../types/markdown"; 2 | 3 | const MARKDOWN_PATTERNS: Record = { 4 | // Fix: 서윤님 피드백 반영 5 | h3: { 6 | regex: /^###$/, 7 | length: 3, 8 | createElement: () => ({ type: "h3", length: 3 }), 9 | }, 10 | h2: { 11 | regex: /^##$/, 12 | length: 2, 13 | createElement: () => ({ type: "h2", length: 2 }), 14 | }, 15 | h1: { 16 | regex: /^#$/, 17 | length: 1, 18 | createElement: () => ({ type: "h1", length: 1 }), 19 | }, 20 | ul: { 21 | regex: /^-$/, 22 | length: 1, 23 | createElement: () => ({ type: "ul", length: 1 }), 24 | }, 25 | ol: { 26 | regex: /^\d\.$/, 27 | length: 2, 28 | createElement: () => ({ type: "ol", length: 2 }), 29 | }, 30 | blockquote: { 31 | regex: /^>$/, 32 | length: 1, 33 | createElement: () => ({ type: "blockquote", length: 1 }), 34 | }, 35 | checkbox1: { 36 | regex: /^\[\]$/, 37 | length: 2, 38 | createElement: () => ({ 39 | type: "checkbox", 40 | length: 2, 41 | }), 42 | }, 43 | checkbox2: { 44 | regex: /^\[\s*\]$/, 45 | length: 3, 46 | createElement: () => ({ 47 | type: "checkbox", 48 | length: 3, 49 | }), 50 | }, 51 | }; 52 | 53 | export const checkMarkdownPattern = (text: string): MarkdownElement | null => { 54 | if (!text) return null; 55 | 56 | for (const pattern of Object.values(MARKDOWN_PATTERNS)) { 57 | const markdownPattern = text.slice(0, pattern.length); 58 | if (pattern.regex.test(markdownPattern)) { 59 | return pattern.createElement(); 60 | } 61 | } 62 | return null; 63 | }; 64 | -------------------------------------------------------------------------------- /client/src/features/page/Page.animation.ts: -------------------------------------------------------------------------------- 1 | export const pageAnimation = { 2 | initial: { 3 | x: 0, 4 | y: 0, 5 | opacity: 0, 6 | scale: 0.8, 7 | }, 8 | animate: ({ x, y, isActive }: { x: number; y: number; isActive: boolean }) => ({ 9 | x, 10 | y, 11 | opacity: 1, 12 | scale: 1, 13 | boxShadow: isActive ? "0 8px 30px rgba(0,0,0,0.15)" : "0 2px 10px rgba(0,0,0,0.1)", 14 | transition: { 15 | x: { type: "tween", duration: 0.03, ease: "linear" }, 16 | y: { type: "tween", duration: 0.03, ease: "linear" }, 17 | scale: { type: "spring", stiffness: 300, damping: 15 }, 18 | }, 19 | }), 20 | }; 21 | 22 | export const resizeHandleAnimation = { 23 | whileHover: { 24 | scale: 1.1, 25 | boxShadow: "0 8px 16px #00000030", 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /client/src/features/page/Page.style.ts: -------------------------------------------------------------------------------- 1 | import { css, cx } from "@styled-system/css"; 2 | import { glassContainer } from "@styled-system/recipes"; 3 | 4 | export const pageContainer = cx( 5 | glassContainer({ border: "lg" }), 6 | css({ 7 | display: "flex", 8 | position: "absolute", 9 | flexDirection: "column", 10 | width: "450px", 11 | height: "400px", 12 | }), 13 | ); 14 | 15 | export const pageHeader = css({ 16 | display: "flex", 17 | justifyContent: "space-between", 18 | alignItems: "center", 19 | borderTopRadius: "md", 20 | height: "60px", 21 | padding: "sm", 22 | boxShadow: "xs", 23 | backdropFilter: "blur(30px)", 24 | "&:hover": { 25 | cursor: "move", 26 | }, 27 | }); 28 | 29 | const baseResizeHandle = css({ 30 | zIndex: 1, 31 | position: "absolute", 32 | }); 33 | 34 | export const resizeHandles = { 35 | top: cx( 36 | baseResizeHandle, 37 | css({ 38 | top: "-5px", 39 | left: "5px", 40 | right: "5px", 41 | height: "10px", 42 | cursor: "n-resize", 43 | }), 44 | ), 45 | 46 | bottom: cx( 47 | baseResizeHandle, 48 | css({ 49 | left: "5px", 50 | right: "5px", 51 | bottom: "-5px", 52 | height: "10px", 53 | cursor: "s-resize", 54 | }), 55 | ), 56 | 57 | left: cx( 58 | baseResizeHandle, 59 | css({ 60 | top: "5px", 61 | left: "-5px", 62 | bottom: "5px", 63 | width: "10px", 64 | cursor: "w-resize", 65 | }), 66 | ), 67 | 68 | right: cx( 69 | baseResizeHandle, 70 | css({ 71 | top: "5px", 72 | right: "-5px", 73 | bottom: "5px", 74 | width: "10px", 75 | cursor: "e-resize", 76 | }), 77 | ), 78 | 79 | topLeft: cx( 80 | baseResizeHandle, 81 | css({ 82 | top: "-10px", 83 | left: "-10px", 84 | width: "24px", 85 | height: "24px", 86 | cursor: "nw-resize", 87 | }), 88 | ), 89 | 90 | topRight: cx( 91 | baseResizeHandle, 92 | css({ 93 | top: "-10px", 94 | right: "-10px", 95 | width: "24px", 96 | height: "24px", 97 | cursor: "ne-resize", 98 | }), 99 | ), 100 | 101 | bottomLeft: cx( 102 | baseResizeHandle, 103 | css({ 104 | left: "-10px", 105 | bottom: "-10px", 106 | width: "24px", 107 | height: "24px", 108 | cursor: "sw-resize", 109 | }), 110 | ), 111 | 112 | bottomRight: cx( 113 | baseResizeHandle, 114 | css({ 115 | right: "-10px", 116 | bottom: "-10px", 117 | width: "24px", 118 | height: "24px", 119 | cursor: "se-resize", 120 | }), 121 | ), 122 | }; 123 | -------------------------------------------------------------------------------- /client/src/features/page/Page.tsx: -------------------------------------------------------------------------------- 1 | import { PageIconType, serializedEditorDataProps } from "@noctaCrdt/Interfaces"; 2 | import { motion, AnimatePresence } from "framer-motion"; 3 | import { useEffect, useState } from "react"; 4 | import { Editor } from "@features/editor/Editor"; 5 | import { Page as PageType } from "@src/types/page"; 6 | import { pageContainer, pageHeader, resizeHandles } from "./Page.style"; 7 | import { PageControlButton } from "./components/PageControlButton/PageControlButton"; 8 | import { PageTitle } from "./components/PageTitle/PageTitle"; 9 | import { DIRECTIONS, usePage } from "./hooks/usePage"; 10 | 11 | interface PageProps extends PageType { 12 | handlePageSelect: ({ pageId, isSidebar }: { pageId: string; isSidebar?: boolean }) => void; 13 | handlePageClose: (pageId: string) => void; 14 | handleTitleChange: ( 15 | pageId: string, 16 | updates: { title?: string; icon?: PageIconType }, 17 | syncWithServer: boolean, 18 | ) => void; 19 | serializedEditorData: serializedEditorDataProps | null; 20 | } 21 | 22 | export const Page = ({ 23 | id, 24 | x, 25 | y, 26 | title, 27 | zIndex, 28 | icon, 29 | isActive, 30 | handlePageSelect, 31 | handlePageClose, 32 | handleTitleChange, 33 | serializedEditorData, 34 | }: PageProps) => { 35 | const { position, size, isMaximized, pageDrag, pageResize, pageMinimize, pageMaximize } = usePage( 36 | { x, y }, 37 | ); 38 | const [serializedEditorDatas, setSerializedEditorDatas] = 39 | useState(serializedEditorData); 40 | 41 | const onTitleChange = (newTitle: string, syncWithServer: boolean) => { 42 | if (syncWithServer) { 43 | handleTitleChange(id, { title: newTitle }, true); 44 | } else { 45 | handleTitleChange(id, { title: newTitle }, false); 46 | } 47 | }; 48 | 49 | const handlePageClick = () => { 50 | if (!isActive) { 51 | handlePageSelect({ pageId: id }); 52 | } 53 | }; 54 | 55 | // serializedEditorData prop이 변경되면 local state도 업데이트 56 | useEffect(() => { 57 | setSerializedEditorDatas(serializedEditorData); 58 | }, [serializedEditorData]); 59 | 60 | if (!serializedEditorDatas) { 61 | return null; 62 | } 63 | return ( 64 | 65 |
76 |
77 | 78 | handlePageClose(id)} 81 | onPageMaximize={pageMaximize} 82 | onPageMinimize={pageMinimize} 83 | /> 84 |
85 | 91 | {DIRECTIONS.map((direction) => ( 92 | pageResize(e, direction)} 96 | /> 97 | ))} 98 |
99 |
100 | ); 101 | }; 102 | -------------------------------------------------------------------------------- /client/src/features/page/components/PageControlButton/PageControlButton.style.ts: -------------------------------------------------------------------------------- 1 | import { css, cva } from "@styled-system/css"; 2 | 3 | export const pageControlContainer = css({ 4 | display: "flex", 5 | gap: "sm", 6 | _hover: { 7 | "& svg": { 8 | transform: "scale(1)", // 추가 효과 9 | opacity: 1, 10 | }, 11 | }, 12 | }); 13 | 14 | export const pageControlButton = cva({ 15 | base: { 16 | display: "flex", 17 | justifyContent: "center", 18 | alignItems: "center", 19 | borderRadius: "full", 20 | width: "20px", 21 | height: "20px", 22 | cursor: "pointer", 23 | "&:disabled": { 24 | background: "gray.400", 25 | opacity: 0.5, 26 | cursor: "not-allowed", 27 | }, 28 | }, 29 | variants: { 30 | color: { 31 | yellow: { background: "yellow" }, 32 | green: { background: "green" }, 33 | red: { background: "red" }, 34 | }, 35 | }, 36 | }); 37 | 38 | export const iconBox = css({ 39 | transform: "scale(0.8)", 40 | strokeWidth: "2.5px", 41 | width: "14px", 42 | height: "14px", 43 | color: "white/90", 44 | opacity: 0, 45 | transition: "all 0.1s ease", 46 | }); 47 | -------------------------------------------------------------------------------- /client/src/features/page/components/PageControlButton/PageControlButton.tsx: -------------------------------------------------------------------------------- 1 | import CloseIcon from "@assets/icons/close.svg?react"; 2 | import ExpandIcon from "@assets/icons/expand.svg?react"; 3 | import MinusIcon from "@assets/icons/minus.svg?react"; 4 | import { pageControlContainer, pageControlButton, iconBox } from "./PageControlButton.style"; 5 | 6 | interface PageControlButtonProps { 7 | isMaximized: boolean; 8 | onPageMinimize?: () => void; 9 | onPageMaximize?: () => void; 10 | onPageClose?: () => void; 11 | } 12 | 13 | export const PageControlButton = ({ 14 | isMaximized, 15 | onPageMinimize, 16 | onPageMaximize, 17 | onPageClose, 18 | }: PageControlButtonProps) => { 19 | return ( 20 |
21 | 28 | 31 | 34 |
35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /client/src/features/page/components/PageTitle/PageTitle.style.ts: -------------------------------------------------------------------------------- 1 | import { css } from "@styled-system/css"; 2 | 3 | export const pageTitleContainer = css({ 4 | display: "flex", 5 | gap: "8px", 6 | flexDirection: "row", 7 | alignItems: "center", 8 | overflow: "hidden", 9 | }); 10 | 11 | export const pageTitle = css({ 12 | textStyle: "display-medium24", 13 | alignItems: "center", 14 | paddingTop: "3px", 15 | color: "gray.500", 16 | textOverflow: "ellipsis", 17 | overflow: "hidden", 18 | whiteSpace: "nowrap", 19 | }); 20 | -------------------------------------------------------------------------------- /client/src/features/page/components/PageTitle/PageTitle.tsx: -------------------------------------------------------------------------------- 1 | import { PageIconType } from "@noctaCrdt/Interfaces"; 2 | import { iconComponents } from "@src/constants/PageIconButton.config"; 3 | import { pageTitleContainer, pageTitle } from "./PageTitle.style"; 4 | 5 | interface PageTitleProps { 6 | title: string; 7 | icon: PageIconType; 8 | } 9 | 10 | export const PageTitle = ({ title, icon }: PageTitleProps) => { 11 | const { icon: IconComponent, color } = iconComponents[icon]; 12 | return ( 13 |
14 | 15 |

{title || "Title"}

16 |
17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /client/src/features/workSpace/WorkSpace.style.ts: -------------------------------------------------------------------------------- 1 | import { css, cva } from "@styled-system/css"; 2 | 3 | export const content = css({ 4 | position: "relative", 5 | padding: "md", 6 | }); 7 | 8 | export const workSpaceContainer = cva({ 9 | base: { 10 | display: "flex", 11 | width: "100vw", 12 | height: "100vh", 13 | overflow: "hidden", 14 | transition: "opacity 0.3s ease-in-out", 15 | }, 16 | variants: { 17 | visibility: { 18 | visible: { 19 | visibility: "visible", 20 | }, 21 | hidden: { 22 | visibility: "hidden", 23 | }, 24 | }, 25 | opacity: { 26 | 1: { 27 | opacity: 1, 28 | }, 29 | 0: { 30 | opacity: 0, 31 | }, 32 | }, 33 | }, 34 | }); 35 | -------------------------------------------------------------------------------- /client/src/features/workSpace/WorkSpace.tsx: -------------------------------------------------------------------------------- 1 | import { WorkSpace as WorkSpaceClass } from "@noctaCrdt/WorkSpace"; 2 | import { AnimatePresence } from "framer-motion"; 3 | import { useState, useEffect } from "react"; 4 | import { BottomNavigator } from "@components/bottomNavigator/BottomNavigator"; 5 | import { ErrorModal } from "@components/modal/ErrorModal"; 6 | import { Sidebar } from "@components/sidebar/Sidebar"; 7 | import { Page } from "@features/page/Page"; 8 | import { ToastContainer } from "@src/components/Toast/ToastContainer"; 9 | import { useSocketStore } from "@src/stores/useSocketStore"; 10 | import { workSpaceContainer, content } from "./WorkSpace.style"; 11 | import { IntroScreen } from "./components/IntroScreen"; 12 | import { OnboardingOverlay } from "./components/OnboardingOverlay"; 13 | import { usePagesManage } from "./hooks/usePagesManage"; 14 | import { useWorkspaceInit } from "./hooks/useWorkspaceInit"; 15 | 16 | export const WorkSpace = () => { 17 | const [workspace, setWorkspace] = useState(null); 18 | const { isLoading, isInitialized, error } = useWorkspaceInit(); 19 | const { workspace: workspaceMetadata, clientId } = useSocketStore(); 20 | const [showOnboarding, setShowOnboarding] = useState(false); 21 | 22 | const { 23 | pages, 24 | fetchPage, 25 | selectPage, 26 | closePage, 27 | updatePage, 28 | initPages, 29 | // initPagePosition, 30 | openPage, 31 | } = usePagesManage(workspace, clientId); 32 | const visiblePages = pages.filter((page) => page.isVisible && page.isLoaded); 33 | 34 | useEffect(() => { 35 | if (workspaceMetadata) { 36 | const newWorkspace = new WorkSpaceClass( 37 | workspaceMetadata.id, 38 | workspaceMetadata.name, 39 | workspaceMetadata.pageList, 40 | ); 41 | newWorkspace.deserialize(workspaceMetadata); 42 | setWorkspace(newWorkspace); 43 | 44 | initPages(newWorkspace.pageList); 45 | // initPagePosition(); 46 | } 47 | }, [workspaceMetadata]); 48 | 49 | useEffect(() => { 50 | // IntroScreen이 끝나고 초기화가 완료된 후에 지연시켜서 온보딩 표시 51 | if (!isLoading && isInitialized) { 52 | // 약간의 딜레이를 주어 UI가 완전히 렌더링된 후에 온보딩 표시 53 | setTimeout(() => { 54 | setShowOnboarding(true); 55 | }, 500); // 500ms 딜레이 56 | } 57 | }, [isLoading, isInitialized]); 58 | 59 | if (error) { 60 | return ; 61 | } 62 | 63 | return ( 64 | <> 65 | 66 | {isLoading && } 67 | 68 |
74 | 83 |
84 | {visiblePages.map((page) => ( 85 | 92 | ))} 93 |
94 | 102 |
103 | {} 104 | 105 | ); 106 | }; 107 | -------------------------------------------------------------------------------- /client/src/features/workSpace/components/IntroScreen.animation.ts: -------------------------------------------------------------------------------- 1 | export const animation = { 2 | initial: { 3 | y: -100, 4 | opacity: 0, 5 | }, 6 | animate: { 7 | y: 0, 8 | opacity: 1, 9 | }, 10 | transition: { 11 | type: "spring", 12 | stiffness: 300, 13 | damping: 30, 14 | duration: 2, 15 | }, 16 | }; 17 | export const containerVariants = { 18 | hidden: { 19 | opacity: 0, 20 | }, 21 | visible: { 22 | opacity: 1, 23 | }, 24 | }; 25 | 26 | export const topTextVariants = { 27 | hidden: { 28 | opacity: 0, 29 | y: -50, 30 | }, 31 | visible: { 32 | opacity: 1, 33 | y: 0, 34 | transition: { 35 | delay: 1, 36 | duration: 0.4, 37 | ease: "easeOut", 38 | }, 39 | }, 40 | }; 41 | 42 | export const bottomTextVariants = { 43 | hidden: { 44 | opacity: 0, 45 | y: 50, 46 | }, 47 | visible: { 48 | opacity: 1, 49 | y: 0, 50 | transition: { 51 | delay: 2, 52 | duration: 0.3, 53 | ease: "easeOut", 54 | }, 55 | }, 56 | }; 57 | -------------------------------------------------------------------------------- /client/src/features/workSpace/components/IntroScreen.style.ts: -------------------------------------------------------------------------------- 1 | import { css } from "@styled-system/css"; 2 | 3 | export const IntroScreenContainer = css({ 4 | display: "flex", 5 | zIndex: 50, 6 | position: "fixed", 7 | inset: 0, 8 | flexDirection: "column", 9 | justifyContent: "center", 10 | alignItems: "center", 11 | backgroundSize: "cover", 12 | transition: "opacity 0.5s ease-in-out", 13 | }); 14 | 15 | export const topText = css({ 16 | top: "50%", 17 | left: "50%", 18 | transform: "translate(-50%, -160px)", 19 | width: "100%", 20 | color: "gray.900", 21 | textAlign: "center", 22 | fontSize: "2xl", 23 | opacity: 1, 24 | animation: "fadeIn", 25 | textShadow: "0px 0px 5px white", 26 | animationDelay: "0.5s", 27 | }); 28 | 29 | export const bottomText = css({ 30 | top: "50%", 31 | left: "50%", 32 | transform: "translate(-50%, 120px)", 33 | width: "100%", 34 | color: "gray.900", 35 | textAlign: "center", 36 | textShadow: "0px 0px 5px white", 37 | fontSize: "4xl", 38 | fontWeight: "bold", 39 | opacity: 1, 40 | animation: "", 41 | animationDelay: "0.5s", 42 | }); 43 | 44 | export const overLayContainer = css({ 45 | zIndex: -1, 46 | position: "fixed", 47 | top: 0, 48 | left: 0, 49 | width: "100%", 50 | height: "100%", 51 | backgroundColor: "gray.500/30", 52 | }); 53 | -------------------------------------------------------------------------------- /client/src/features/workSpace/components/IntroScreen.tsx: -------------------------------------------------------------------------------- 1 | import { motion } from "framer-motion"; 2 | import { NoctaIcon } from "@components/lotties/NoctaIcon"; 3 | import { containerVariants, bottomTextVariants, topTextVariants } from "./IntroScreen.animation"; 4 | import { IntroScreenContainer, topText, bottomText, overLayContainer } from "./IntroScreen.style"; 5 | 6 | export const IntroScreen = () => { 7 | return ( 8 | 16 | 17 | 밤하늘의 별빛처럼, 자유로운 인터랙션 실시간 에디터 18 | 19 | 20 | 21 | Nocta 22 | 23 |
24 |
25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /client/src/features/workSpace/components/OnboardingOverlay.style.ts: -------------------------------------------------------------------------------- 1 | import { css, cva } from "@styled-system/css"; 2 | 3 | export const overlayContainer = css({ 4 | zIndex: 9999, 5 | position: "fixed", 6 | inset: 0, 7 | }); 8 | 9 | export const highlightBox = css({ 10 | position: "absolute", 11 | borderColor: "rgba(168, 85, 247, 0.5)", // purple-400 with 50% opacity 12 | borderRadius: "xl", 13 | borderWidth: "2px", 14 | backgroundColor: "rgba(255, 255, 255, 0.1)", 15 | pointerEvents: "none", 16 | }); 17 | 18 | export const tooltipBox = css({ 19 | zIndex: 10000, 20 | position: "absolute", 21 | borderRadius: "xl", 22 | width: "280px", 23 | padding: "4", 24 | backgroundColor: "rgba(255, 255, 255, 0.9)", 25 | boxShadow: "xl", 26 | backdropFilter: "blur(4px)", 27 | }); 28 | 29 | export const tooltipTitle = css({ 30 | marginBottom: "2", 31 | color: "purple.900", 32 | fontSize: "lg", 33 | fontWeight: "semibold", 34 | }); 35 | 36 | export const tooltipDescription = css({ 37 | marginBottom: "4", 38 | color: "gray.600", 39 | }); 40 | export const closeButton = css({ 41 | position: "absolute", 42 | top: "2", 43 | right: "2", 44 | borderRadius: "8px", 45 | padding: "1", 46 | color: "gray.500", 47 | transition: "all", 48 | _hover: { 49 | color: "gray.700", 50 | backgroundColor: "gray.100", 51 | }, 52 | "& svg": { 53 | // SVG 스타일 추가 54 | width: "4", 55 | height: "4", 56 | color: "black", 57 | }, 58 | }); 59 | export const IndicatorContainer = css({ display: "flex", gap: "2px" }); 60 | export const stepIndicator = cva({ 61 | base: { 62 | borderRadius: "full", 63 | width: "5px", 64 | height: "5px", 65 | transition: "colors", 66 | }, 67 | variants: { 68 | active: { 69 | true: { 70 | backgroundColor: "purple.500", 71 | }, 72 | false: { 73 | backgroundColor: "gray.300", 74 | }, 75 | }, 76 | }, 77 | }); 78 | 79 | export const nextButton = cva({ 80 | base: { 81 | borderRadius: "lg", 82 | paddingY: "1.5", 83 | paddingX: "4", 84 | fontSize: "sm", 85 | transition: "colors", 86 | }, 87 | variants: { 88 | variant: { 89 | primary: { 90 | color: "white", 91 | backgroundColor: "purple.500", 92 | _hover: { 93 | backgroundColor: "purple.600", 94 | }, 95 | }, 96 | secondary: { 97 | color: "gray.700", 98 | backgroundColor: "gray.100", 99 | _hover: { 100 | backgroundColor: "gray.200", 101 | }, 102 | }, 103 | }, 104 | }, 105 | defaultVariants: { 106 | variant: "primary", 107 | }, 108 | }); 109 | -------------------------------------------------------------------------------- /client/src/features/workSpace/hooks/useWorkspaceInit.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import { useSocketStore } from "@src/stores/useSocketStore"; 3 | 4 | interface UseWorkspaceInitReturn { 5 | isLoading: boolean; 6 | isInitialized: boolean; 7 | error: Error | null; 8 | } 9 | 10 | export const useWorkspaceInit = (): UseWorkspaceInitReturn => { 11 | const [isLoading, setIsLoading] = useState(true); 12 | const [isInitialized, setIsInitialized] = useState(false); 13 | const [error, setError] = useState(null); 14 | const { socket } = useSocketStore(); 15 | 16 | const isFirstVisit = !sessionStorage.getItem("hasVisitedBefore"); 17 | 18 | useEffect(() => { 19 | const initializeWorkspace = async () => { 20 | try { 21 | // 첫 방문이 아니면 IntroScreen 시간을 0으로 설정 22 | const IntroWaitTime = isFirstVisit ? 4700 : 0; 23 | 24 | await new Promise((resolve) => setTimeout(resolve, IntroWaitTime)); 25 | 26 | // 첫 방문 표시 저장 (sessionStorage 사용) 27 | if (isFirstVisit) { 28 | sessionStorage.setItem("hasVisitedBefore", "true"); 29 | } 30 | setIsInitialized(true); 31 | } catch (err) { 32 | setError(err instanceof Error ? err : new Error("Failed to initialize workspace")); 33 | } finally { 34 | // 페이드 아웃 효과를 위한 약간의 딜레이 35 | setTimeout(() => { 36 | setIsLoading(false); 37 | }, 500); 38 | } 39 | }; 40 | 41 | initializeWorkspace(); 42 | }, [socket, isFirstVisit]); 43 | 44 | return { isLoading: isLoading && isFirstVisit, isInitialized, error }; 45 | }; 46 | -------------------------------------------------------------------------------- /client/src/index.css: -------------------------------------------------------------------------------- 1 | @layer reset, base, tokens, recipes, utilities; 2 | -------------------------------------------------------------------------------- /client/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 2 | import { StrictMode } from "react"; 3 | import { createRoot } from "react-dom/client"; 4 | import "./index.css"; 5 | import App from "./App.tsx"; 6 | 7 | const queryClient = new QueryClient({ 8 | defaultOptions: { 9 | queries: { 10 | refetchOnWindowFocus: false, 11 | }, 12 | }, 13 | }); 14 | 15 | createRoot(document.getElementById("root")!).render( 16 | 17 | 18 | 19 | 20 | , 21 | ); 22 | -------------------------------------------------------------------------------- /client/src/stores/useErrorStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | interface ErrorStore { 4 | isErrorModalOpen: boolean; 5 | errorMessage: string; 6 | setErrorModal: (isOpen: boolean, message?: string) => void; 7 | } 8 | 9 | export const useErrorStore = create((set) => ({ 10 | isErrorModalOpen: false, 11 | errorMessage: "", 12 | setErrorModal: (isOpen, message = "") => set({ isErrorModalOpen: isOpen, errorMessage: message }), 13 | })); 14 | -------------------------------------------------------------------------------- /client/src/stores/useSidebarStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | interface SidebarStore { 4 | isSidebarOpen: boolean; 5 | actions: { 6 | toggleSidebar: () => void; 7 | }; 8 | } 9 | 10 | const useSidebarStore = create((set) => ({ 11 | isSidebarOpen: true, 12 | actions: { 13 | toggleSidebar: () => set((state) => ({ isSidebarOpen: !state.isSidebarOpen })), 14 | }, 15 | })); 16 | 17 | export const useIsSidebarOpen = () => useSidebarStore((state) => state.isSidebarOpen); 18 | export const useSidebarActions = () => useSidebarStore((state) => state.actions); 19 | -------------------------------------------------------------------------------- /client/src/stores/useToastStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { Toast } from "../types/toast"; 3 | 4 | interface ToastStore { 5 | toasts: Toast[]; 6 | addToast: (message: string, duration?: number) => void; 7 | removeToast: (id: number) => void; 8 | } 9 | 10 | export const useToastStore = create((set) => ({ 11 | toasts: [], 12 | addToast: (message, duration = 3000) => { 13 | const id = Date.now(); 14 | set((state) => ({ 15 | toasts: [...state.toasts, { id, message, duration }], 16 | })); 17 | // duration 후에 자동으로 해당 toast 제거 18 | setTimeout(() => { 19 | set((state) => ({ 20 | toasts: state.toasts.filter((toast) => toast.id !== id), 21 | })); 22 | }, duration); 23 | }, 24 | 25 | removeToast: (id) => 26 | set((state) => ({ 27 | toasts: state.toasts.filter((toast) => toast.id !== id), 28 | })), 29 | })); 30 | -------------------------------------------------------------------------------- /client/src/stores/useUserStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { persist, createJSONStorage } from "zustand/middleware"; 3 | import { useShallow } from "zustand/shallow"; 4 | 5 | interface UserStore { 6 | id: string | null; 7 | name: string | null; 8 | accessToken: string | null; 9 | actions: { 10 | setUserInfo: (id: string, name: string, accessToken: string) => void; 11 | removeUserInfo: () => void; 12 | getUserInfo: () => { id: string | null; name: string | null; accessToken: string | null }; 13 | updateAccessToken: (accessToken: string) => void; 14 | checkAuth: () => boolean; 15 | }; 16 | } 17 | 18 | export const useUserStore = create()( 19 | persist( 20 | (set, get) => ({ 21 | id: null, 22 | name: null, 23 | accessToken: null, 24 | actions: { 25 | setUserInfo: (id: string, name: string, accessToken: string) => 26 | set(() => ({ id, name, accessToken })), 27 | removeUserInfo: () => { 28 | set(() => ({ id: null, name: null, accessToken: null })); 29 | sessionStorage.removeItem("nocta-storage"); 30 | }, 31 | getUserInfo: () => { 32 | const state = get(); 33 | return { 34 | id: state.id, 35 | name: state.name, 36 | accessToken: state.accessToken, 37 | }; 38 | }, 39 | updateAccessToken: (accessToken: string) => set(() => ({ accessToken })), 40 | checkAuth: () => { 41 | const state = get(); 42 | return !!(state.id && state.name && state.accessToken); 43 | }, 44 | }, 45 | }), 46 | { 47 | name: "nocta-storage", 48 | storage: createJSONStorage(() => sessionStorage), 49 | partialize: (state) => ({ 50 | id: state.id, 51 | name: state.name, 52 | accessToken: state.accessToken, 53 | }), 54 | }, 55 | ), 56 | ); 57 | 58 | // store 값을 변경하는 부분. 주로 api 코드에서 사용 59 | export const useUserActions = () => useUserStore((state) => state.actions); 60 | 61 | // store 값을 사용하는 부분. 주로 component 내부에서 사용 62 | export const useUserInfo = () => 63 | useUserStore( 64 | // state 바뀜에 따라 재렌더링 되도록 65 | useShallow((state) => ({ 66 | userId: state.id, 67 | name: state.name, 68 | accessToken: state.accessToken, 69 | })), 70 | ); 71 | 72 | export const useCheckLogin = () => 73 | useUserStore((state) => !!(state.id && state.name && state.accessToken)); 74 | -------------------------------------------------------------------------------- /client/src/stores/useWorkspaceStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | // 워크스페이스 권한 타입 정의 4 | 5 | interface WorkspaceStore { 6 | // 현재 선택된 워크스페이스의 권한 7 | currentRole: string | null; 8 | currentWorkspaceName: string | null; 9 | currentActiveUsers: number | null; 10 | currentMemberCount: number | null; 11 | setCurrentRole: (role: string | null) => void; 12 | setCurrentWorkspaceName: (name: string | null) => void; 13 | setCurrentActiveUsers: (count: number | null) => void; 14 | setCurrentMemberCount: (count: number | null) => void; 15 | } 16 | 17 | export const useWorkspaceStore = create((set) => ({ 18 | currentRole: null, 19 | setCurrentRole: (role) => set({ currentRole: role }), 20 | currentWorkspaceName: null, 21 | setCurrentWorkspaceName: (name) => set({ currentWorkspaceName: name }), 22 | currentActiveUsers: null, 23 | setCurrentActiveUsers: (count) => set({ currentActiveUsers: count }), 24 | currentMemberCount: null, 25 | setCurrentMemberCount: (count) => set({ currentMemberCount: count }), 26 | })); 27 | -------------------------------------------------------------------------------- /client/src/styles/global.ts: -------------------------------------------------------------------------------- 1 | import { defineGlobalStyles } from "@pandacss/dev"; 2 | 3 | export const globalStyles = defineGlobalStyles({ 4 | "@font-face": { 5 | fontFamily: "Pretendard", 6 | src: 'url("./assets/fonts/Pretendard-Medium.woff2") format("woff2")', 7 | fontWeight: 500, 8 | fontStyle: "normal", 9 | }, 10 | 11 | "html, body": { 12 | fontFamily: "Pretendard, sans-serif", 13 | boxSizing: "border-box", 14 | }, 15 | // 스크롤바 전체 16 | "::-webkit-scrollbar": { 17 | width: "8px", 18 | }, 19 | 20 | // 스크롤바 전체 영역 21 | "::-webkit-scrollbar-track": { 22 | background: "transparent", 23 | marginBottom: "12px", 24 | }, 25 | 26 | // 스크롤바 핸들 27 | "::-webkit-scrollbar-thumb": { 28 | background: "white/50", 29 | borderRadius: "lg", 30 | }, 31 | 32 | "input:-webkit-autofill": { 33 | transition: "background-color 5000s ease-in-out 0s", 34 | }, 35 | }); 36 | -------------------------------------------------------------------------------- /client/src/styles/recipes/glassContainerRecipe.ts: -------------------------------------------------------------------------------- 1 | import { defineRecipe } from "@pandacss/dev"; 2 | 3 | export const glassContainerRecipe = defineRecipe({ 4 | className: "glassContainer", 5 | base: { 6 | borderRadius: "md", 7 | background: "linear-gradient(180deg, token(colors.white/60), token(colors.white/0))", 8 | boxShadow: "lg", 9 | backdropFilter: "blur(20px)", 10 | }, 11 | variants: { 12 | borderRadius: { 13 | top: { 14 | borderBottomRadius: "none", 15 | }, 16 | bottom: { 17 | borderTopRadius: "none", 18 | }, 19 | right: { 20 | borderLeftRadius: "none", 21 | }, 22 | left: { 23 | borderRightRadius: "none", 24 | }, 25 | }, 26 | 27 | border: { 28 | md: { 29 | border: "1px solid token(colors.white/40)", 30 | }, 31 | lg: { 32 | border: "2px solid token(colors.white/40)", 33 | }, 34 | }, 35 | background: { 36 | none: { 37 | background: "token(colors.white/95)", 38 | }, 39 | }, 40 | boxShadow: { 41 | all: { 42 | boxShadow: "lg", 43 | }, 44 | top: { 45 | boxShadow: "0 -4px 6px -1px rgb(0 0 0 / 0.1), 0 -2px 4px -2px rgb(0 0 0 / 0.1)", 46 | }, 47 | bottom: { 48 | boxShadow: "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)", 49 | }, 50 | left: { 51 | boxShadow: "-4px 0 6px -1px rgb(0 0 0 / 0.1), -2px 0 4px -2px rgb(0 0 0 / 0.1)", 52 | }, 53 | right: { 54 | boxShadow: "4px 0 6px -1px rgb(0 0 0 / 0.1), 2px 0 4px -2px rgb(0 0 0 / 0.1)", 55 | }, 56 | }, 57 | }, 58 | }); 59 | -------------------------------------------------------------------------------- /client/src/styles/tokens/color.ts: -------------------------------------------------------------------------------- 1 | import { COLOR } from "@constants/color"; 2 | 3 | export const colors = { 4 | white: { 5 | value: COLOR.WHITE, 6 | }, 7 | gray: { 8 | 100: { value: COLOR.GRAY_100 }, 9 | 300: { value: COLOR.GRAY_300 }, 10 | 500: { value: COLOR.GRAY_500 }, 11 | 700: { value: COLOR.GRAY_700 }, 12 | 900: { value: COLOR.GRAY_900 }, 13 | }, 14 | shadow: { 15 | value: COLOR.SHADOW, 16 | }, 17 | red: { 18 | value: COLOR.RED, 19 | }, 20 | yellow: { 21 | value: COLOR.YELLOW, 22 | }, 23 | green: { 24 | value: COLOR.GREEN, 25 | }, 26 | purple: { 27 | value: COLOR.PURPLE, 28 | }, 29 | brown: { 30 | value: COLOR.BROWN, 31 | }, 32 | blue: { 33 | value: COLOR.BLUE, 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /client/src/styles/tokens/radii.ts: -------------------------------------------------------------------------------- 1 | export const radii = { 2 | none: { value: "0px" }, 3 | xs: { value: "16px" }, 4 | sm: { value: "20px" }, 5 | md: { value: "24px" }, 6 | full: { value: "9999px" }, 7 | }; 8 | -------------------------------------------------------------------------------- /client/src/styles/tokens/shadow.ts: -------------------------------------------------------------------------------- 1 | import { colors } from "./color"; 2 | 3 | export const shadows = { 4 | xs: { 5 | // page -> title 그림자 6 | value: { 7 | offsetX: 0, 8 | offsetY: 0, 9 | blur: 15, 10 | spread: 0, 11 | color: `${colors.shadow.value}05`, 12 | }, 13 | }, 14 | sm: { 15 | // sidebar -> menuButton 그림자 16 | value: { 17 | offsetX: 0, 18 | offsetY: 3, 19 | blur: 15, 20 | spread: 0, 21 | color: `${colors.shadow.value}10`, 22 | }, 23 | }, 24 | md: { 25 | // button 그림자 (bottomNavigator 버튼 + sidebar 페이지 추가 버튼) 26 | value: { 27 | offsetX: 0, 28 | offsetY: 4, 29 | blur: 15, 30 | spread: 0, 31 | color: `${colors.shadow.value}15`, 32 | }, 33 | }, 34 | lg: { 35 | // page 그림자 36 | value: { 37 | offsetX: 0, 38 | offsetY: 0, 39 | blur: 15, 40 | spread: 0, 41 | color: `${colors.shadow.value}20`, 42 | }, 43 | }, 44 | }; 45 | -------------------------------------------------------------------------------- /client/src/styles/tokens/sizes.ts: -------------------------------------------------------------------------------- 1 | import { SIDE_BAR } from "@constants/size"; 2 | 3 | export const sizes = { 4 | sidebar: { 5 | width: { value: `${SIDE_BAR.WIDTH}px` }, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /client/src/styles/tokens/spacing.ts: -------------------------------------------------------------------------------- 1 | import { SPACING } from "@constants/spacing"; 2 | 3 | export const spacing = { 4 | sm: { value: `${SPACING.SMALL}px` }, 5 | md: { value: `${SPACING.MEDIUM}px` }, 6 | lg: { value: `${SPACING.LARGE}px` }, 7 | }; 8 | -------------------------------------------------------------------------------- /client/src/styles/typography.ts: -------------------------------------------------------------------------------- 1 | import { defineTextStyles } from "@pandacss/dev"; 2 | 3 | export const textStyles = defineTextStyles({ 4 | "display-medium16": { 5 | value: { 6 | fontFamily: "pretendard", 7 | fontWeight: "500", 8 | fontSize: "16px", 9 | lineHeight: "24px", 10 | letterSpacing: "0", 11 | textDecoration: "None", 12 | textTransform: "None", 13 | }, 14 | }, 15 | 16 | "display-medium20": { 17 | value: { 18 | fontFamily: "pretendard", 19 | fontWeight: "500", 20 | fontSize: "20px", 21 | lineHeight: "28px", 22 | letterSpacing: "0", 23 | textDecoration: "None", 24 | textTransform: "None", 25 | }, 26 | }, 27 | 28 | "display-medium24": { 29 | value: { 30 | fontFamily: "pretendard", 31 | fontWeight: "500", 32 | fontSize: "24px", 33 | lineHeight: "32px", 34 | letterSpacing: "0", 35 | textDecoration: "None", 36 | textTransform: "None", 37 | }, 38 | }, 39 | 40 | "display-medium28": { 41 | value: { 42 | fontFamily: "pretendard", 43 | fontWeight: "700", 44 | fontSize: "28px", 45 | lineHeight: "36px", 46 | letterSpacing: "-0.2px", 47 | textDecoration: "None", 48 | textTransform: "None", 49 | }, 50 | }, 51 | 52 | "display-medium32": { 53 | value: { 54 | fontFamily: "pretendard", 55 | fontWeight: "700", 56 | fontSize: "32px", 57 | lineHeight: "40px", 58 | letterSpacing: "-0.2px", 59 | textDecoration: "None", 60 | textTransform: "None", 61 | }, 62 | }, 63 | 64 | bold: { 65 | value: { 66 | fontWeight: "bold", 67 | }, 68 | }, 69 | italic: { 70 | value: { 71 | fontStyle: "italic", 72 | }, 73 | }, 74 | underline: { 75 | value: { 76 | textDecoration: "underline", 77 | }, 78 | }, 79 | strikethrough: { 80 | value: { 81 | textDecoration: "line-through", 82 | }, 83 | }, 84 | "underline-strikethrough": { 85 | value: { 86 | textDecoration: "underline line-through", 87 | }, 88 | }, 89 | }); 90 | -------------------------------------------------------------------------------- /client/src/types/markdown.ts: -------------------------------------------------------------------------------- 1 | import { ElementType } from "@noctaCrdt/Interfaces"; 2 | 3 | export interface MarkdownElement { 4 | type: ElementType; 5 | length: number; 6 | } 7 | 8 | export interface MarkdownPattern { 9 | regex: RegExp; 10 | length: number; 11 | createElement: () => MarkdownElement; 12 | } 13 | -------------------------------------------------------------------------------- /client/src/types/page.ts: -------------------------------------------------------------------------------- 1 | import { serializedEditorDataProps, PageIconType } from "@noctaCrdt/Interfaces"; 2 | 3 | export interface Page { 4 | id: string; 5 | title: string; 6 | icon: PageIconType; 7 | x: number; 8 | y: number; 9 | zIndex: number; 10 | isActive: boolean; 11 | isVisible: boolean; 12 | isLoaded: boolean; 13 | serializedEditorData: serializedEditorDataProps | null; 14 | } 15 | 16 | export interface Position { 17 | x: number; 18 | y: number; 19 | } 20 | 21 | export interface Size { 22 | width: number; 23 | height: number; 24 | } 25 | -------------------------------------------------------------------------------- /client/src/types/toast.ts: -------------------------------------------------------------------------------- 1 | export interface Toast { 2 | id: number; 3 | message: string; 4 | duration: number; 5 | } 6 | -------------------------------------------------------------------------------- /client/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | // / 2 | -------------------------------------------------------------------------------- /client/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "jsx": "react-jsx" 8 | }, 9 | "include": ["src/**/*", "vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "moduleResolution": "bundler", 9 | "allowImportingTsExtensions": true, 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "noEmit": true, 13 | "jsx": "react-jsx", 14 | "strict": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "composite": true, 19 | "types": ["vite/client", "vite-plugin-svgr/client"], 20 | "baseUrl": ".", 21 | "paths": { 22 | "@src/*": ["src/*"], 23 | "@styled-system/*": ["styled-system/*"], 24 | "@components/*": ["src/components/*"], 25 | "@assets/*": ["src/assets/*"], 26 | "@features/*": ["src/features/*"], 27 | "@styles/*": ["src/styles/*"], 28 | "@constants/*": ["src/constants/*"], 29 | "@hooks/*": ["src/hooks/*"], 30 | "@utils/*": ["src/utils/*"], 31 | "@apis/*": ["src/apis/*"], 32 | "@stores/*": ["src/stores/*"], 33 | "@noctaCrdt": ["../@noctaCrdt/dist"], 34 | "@noctaCrdt/*": ["../@noctaCrdt/dist/*"] 35 | } 36 | }, 37 | "references": [{ "path": "../@noctaCrdt" }], 38 | "include": ["src", "*.ts", "*.tsx", "vite.config.ts", "styled-system"], 39 | "exclude": ["node_modules"] 40 | } 41 | -------------------------------------------------------------------------------- /client/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "composite": true, 9 | /* Bundler mode */ 10 | "moduleResolution": "Bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /client/vite-env-override.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.svg" { 2 | const content: React.FC>; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import path from "path"; 3 | import react from "@vitejs/plugin-react"; 4 | import tsconfigPaths from "vite-tsconfig-paths"; 5 | import svgr from "vite-plugin-svgr"; 6 | 7 | export default defineConfig({ 8 | plugins: [react(), tsconfigPaths(), svgr()], 9 | resolve: { 10 | alias: { "@noctaCrdt": path.resolve(__dirname, "../@noctaCrdt") }, 11 | }, 12 | publicDir: "public", 13 | }); 14 | -------------------------------------------------------------------------------- /client/vite.env.d.ts: -------------------------------------------------------------------------------- 1 | // / 2 | // / 3 | -------------------------------------------------------------------------------- /docker-compose.debug.yml: -------------------------------------------------------------------------------- 1 | services: 2 | frontend: 3 | build: 4 | context: . 5 | dockerfile: ./client/Dockerfile.dev 6 | volumes: 7 | - ./client/dist:/app/client/dist 8 | ports: 9 | - "5173:5173" # React 개발 서버 포트 10 | environment: 11 | - NODE_ENV=development 12 | command: pnpm --filter client run dev 13 | 14 | backend: 15 | build: 16 | context: . 17 | dockerfile: ./server/Dockerfile.dev 18 | ports: 19 | - "3000:3000" # NestJS 개발 서버 포트 20 | - "9229:9229" # Node.js 디버깅 포트 21 | environment: 22 | - MONGO_URI=mongodb://localhost:27017/boost 23 | - NODE_ENV=development 24 | 25 | nginx: 26 | build: 27 | context: . 28 | dockerfile: ./nginx/Dockerfile.dev 29 | ports: 30 | - "80:80" 31 | volumes: 32 | - ./client/dist:/usr/share/nginx/html 33 | depends_on: 34 | - frontend 35 | - backend 36 | 37 | networks: 38 | app-network: 39 | driver: bridge 40 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | frontend: 3 | build: 4 | context: . 5 | dockerfile: ./client/Dockerfile 6 | volumes: 7 | - ./client/dist:/app/client/dist 8 | environment: 9 | - NODE_ENV=${NODE_ENV} 10 | - VITE_API_URL=${VITE_API_URL} 11 | command: pnpm --filter client run build 12 | 13 | backend: 14 | build: 15 | context: . 16 | dockerfile: ./server/Dockerfile 17 | ports: 18 | - "3000:3000" 19 | environment: 20 | - MONGO_URI=${MONGO_URI} 21 | - NODE_ENV=${NODE_ENV} 22 | - PORT=3000 23 | - JWT_SECRET=${JWT_SECRET} 24 | - JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET} 25 | 26 | nginx: 27 | build: 28 | context: . 29 | dockerfile: ./nginx/Dockerfile 30 | ports: 31 | - "80:80" 32 | - "443:443" 33 | volumes: 34 | - ./client/dist:/usr/share/nginx/html 35 | - ~/certbot/www:/var/www/certbot 36 | - /etc/letsencrypt:/etc/letsencrypt:ro 37 | depends_on: 38 | - frontend 39 | - backend 40 | 41 | networks: 42 | app-network: 43 | driver: bridge 44 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import tseslint from "@typescript-eslint/eslint-plugin"; 3 | import tsparser from "@typescript-eslint/parser"; 4 | import prettierPlugin from "eslint-plugin-prettier"; 5 | import importPlugin from "eslint-plugin-import"; 6 | 7 | const airbnbRules = { 8 | // Airbnb 스타일 가이드의 핵심 규칙들 9 | "no-var": "error", 10 | "prefer-const": "error", 11 | "prefer-template": "error", 12 | "no-param-reassign": "error", 13 | "object-shorthand": "error", 14 | "prefer-destructuring": ["error", { array: true, object: true }], 15 | "no-array-constructor": "error", 16 | "func-style": ["error", "expression"], 17 | "arrow-parens": ["error", "always"], 18 | "arrow-body-style": ["warn", "as-needed"], 19 | "no-duplicate-imports": "error", 20 | "one-var": ["error", "never"], 21 | "no-plusplus": ["error", { allowForLoopAfterthoughts: true }], 22 | "spaced-comment": ["error", "always"], 23 | "no-underscore-dangle": "off", 24 | "max-len": ["warn", { code: 100, ignoreComments: true }], 25 | 26 | // Import 규칙 27 | "import/prefer-default-export": "off", 28 | "import/no-default-export": "off", 29 | "import/extensions": "off", 30 | "import/no-extraneous-dependencies": "off", 31 | "import/first": "error", 32 | "import/newline-after-import": "error", 33 | "import/no-duplicates": "error", 34 | }; 35 | 36 | /** @type {import('eslint').Linter.FlatConfig[]} */ 37 | const config = [ 38 | js.configs.recommended, 39 | // 공통 설정 40 | { 41 | files: ["**/*.{ts,tsx,js,jsx}"], 42 | plugins: { 43 | "@typescript-eslint": tseslint, 44 | prettier: prettierPlugin, 45 | import: importPlugin, 46 | }, 47 | languageOptions: { 48 | parser: tsparser, 49 | ecmaVersion: 2022, 50 | sourceType: "module", 51 | parserOptions: { 52 | ecmaFeatures: { 53 | jsx: true, 54 | }, 55 | }, 56 | }, 57 | rules: { 58 | // Airbnb 규칙 59 | ...airbnbRules, 60 | 61 | // TypeScript 62 | ...tseslint.configs.recommended.rules, 63 | "@typescript-eslint/no-unused-vars": [ 64 | "warn", 65 | { 66 | varsIgnorePattern: 67 | "^(js|Injectable|Controller|Get|Post|Put|Delete|Patch|Options|Head|All)$", 68 | argsIgnorePattern: "^_", 69 | ignoreRestSiblings: true, 70 | }, 71 | ], 72 | "@typescript-eslint/explicit-function-return-type": "off", 73 | "@typescript-eslint/explicit-module-boundary-types": "off", 74 | "@typescript-eslint/no-explicit-any": "warn", 75 | 76 | // Prettier 77 | ...prettierPlugin.configs.recommended.rules, 78 | 79 | // 개발 초기 단계를 위한 규칙 완화 80 | "no-console": "off", 81 | "no-unused-vars": "off", // TypeScript rule을 대신 사용 82 | "no-undef": "off", // TypeScript에서 처리 83 | }, 84 | }, 85 | { 86 | ignores: [ 87 | "**/node_modules/", 88 | "**/dist/", 89 | "**/build/", 90 | "**/coverage/", 91 | "**/*.config.js", 92 | "**/src/**/*.test.js", 93 | "**/public/*", 94 | "client/styled-system/", 95 | ], 96 | }, 97 | // 설정 파일에 대한 특별 규칙 98 | { 99 | files: ["**/eslint.config.js", "**/prettier.config.js", "**/vite.config.ts"], 100 | rules: { 101 | "@typescript-eslint/no-unused-vars": "off", 102 | "import/no-unused-modules": "off", 103 | }, 104 | }, 105 | ]; 106 | 107 | export default config; 108 | -------------------------------------------------------------------------------- /nginx/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:alpine 2 | WORKDIR /usr/share/nginx/html 3 | 4 | # Nginx 기본 설정 복사 5 | COPY ./nginx/default.conf /etc/nginx/conf.d/default.conf 6 | 7 | EXPOSE 80 8 | EXPOSE 443 9 | CMD ["nginx", "-g", "daemon off;"] -------------------------------------------------------------------------------- /nginx/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM nginx:alpine 2 | WORKDIR /usr/share/nginx/html 3 | 4 | # Nginx 기본 설정 복사 5 | COPY ./nginx/default.dev.conf /etc/nginx/conf.d/default.conf 6 | 7 | # 정적 파일을 호스트에서 복사 (개발 환경에서 핫 리로딩 사용 가능) 8 | EXPOSE 80 9 | CMD ["nginx", "-g", "daemon off;"] -------------------------------------------------------------------------------- /nginx/default.conf: -------------------------------------------------------------------------------- 1 | # 백엔드 서버 정의 2 | upstream backend { 3 | server backend:3000; # 백엔드 서버 (NestJS) 4 | } 5 | 6 | # HTTP 서버 블록 7 | server { 8 | listen 80; 9 | server_name nocta.site www.nocta.site; 10 | 11 | # Let's Encrypt 인증을 위한 설정 12 | location /.well-known/acme-challenge/ { 13 | root /var/www/certbot; 14 | } 15 | 16 | # HTTPS로 리다이렉트 17 | location / { 18 | return 301 https://$host$request_uri; 19 | } 20 | } 21 | 22 | # HTTPS 서버 블록 23 | server { 24 | listen 443 ssl; 25 | server_name nocta.site www.nocta.site; 26 | 27 | # SSL 인증서와 키 파일 경로 28 | ssl_certificate /etc/letsencrypt/live/nocta.site/fullchain.pem; 29 | ssl_certificate_key /etc/letsencrypt/live/nocta.site/privkey.pem; 30 | 31 | # SSL 설정 32 | ssl_protocols TLSv1.2 TLSv1.3; 33 | ssl_prefer_server_ciphers on; 34 | ssl_ciphers HIGH:!aNULL:!MD5; 35 | 36 | # HSTS 설정 (HTTPS 강제) 37 | add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; 38 | 39 | location = /robots.txt { 40 | root /usr/share/nginx/html; # React 빌드 결과물이 있는 디렉토리 41 | access_log off; 42 | add_header Cache-Control "public, max-age=86400"; # 24시간 캐싱 43 | } 44 | 45 | location = /sitemap.xml { 46 | root /usr/share/nginx/html; 47 | access_log off; 48 | add_header Cache-Control "public, max-age=86400"; 49 | } 50 | 51 | # /api 경로로 들어오는 요청은 백엔드로 전달 52 | location /api { 53 | proxy_pass http://backend; # 백엔드로 요청 전달 54 | proxy_http_version 1.1; 55 | proxy_set_header Upgrade $http_upgrade; 56 | proxy_set_header Connection "Upgrade"; 57 | proxy_set_header Host $host; 58 | proxy_set_header X-Real-IP $remote_addr; 59 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 60 | } 61 | 62 | # 정적 파일을 제공하는 기본 경로 설정 63 | location / { 64 | root /usr/share/nginx/html; # React 빌드 결과물이 위치한 디렉터리 65 | index index.html; # 기본 진입점 파일 66 | try_files $uri /index.html; # SPA 라우팅 지원 67 | } 68 | 69 | # 404 에러 페이지 설정 70 | error_page 404 /404.html; 71 | location = /404.html { 72 | root /usr/share/nginx/html; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /nginx/default.dev.conf: -------------------------------------------------------------------------------- 1 | # 백엔드와 프론트엔드에 대한 업스트림 서버 정의 2 | upstream backend { 3 | server backend:3000; # 백엔드 서버 (NestJS) 4 | } 5 | 6 | upstream frontend { 7 | server frontend:5173; # 프론트엔드 서버 (React) 8 | } 9 | 10 | server { 11 | listen 80; 12 | 13 | location = /robots.txt { 14 | proxy_pass http://frontend; # 개발 서버로 전달 15 | access_log off; 16 | } 17 | 18 | location = /sitemap.xml { 19 | proxy_pass http://frontend; 20 | access_log off; 21 | } 22 | 23 | # /api 경로로 들어오는 요청은 백엔드로 전달 24 | location /api { 25 | proxy_pass http://backend; # 백엔드로 요청 전달 26 | proxy_http_version 1.1; 27 | proxy_set_header Upgrade $http_upgrade; 28 | proxy_set_header Connection "Upgrade"; 29 | proxy_set_header Host $host; 30 | proxy_set_header X-Real-IP $remote_addr; 31 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 32 | } 33 | 34 | # 기본 경로는 프론트엔드로 전달 35 | location / { 36 | proxy_pass http://frontend; 37 | } 38 | 39 | # /sockjs-node 경로 (React의 핫 리로딩 웹소켓 연결) 40 | location /sockjs-node { 41 | proxy_pass http://frontend; 42 | proxy_http_version 1.1; 43 | proxy_set_header Upgrade $http_upgrade; 44 | proxy_set_header Connection "Upgrade"; 45 | } 46 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web33-Nocta", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "test": "pnpm --filter server test", 9 | "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", 10 | "lint": "eslint . --fix", 11 | "lint:client": "eslint \"client/src/**/*.{ts,tsx}\" --fix", 12 | "lint:server": "eslint \"server/src/**/*.{ts,tsx}\" --fix", 13 | "build": "pnpm build:lib && pnpm -r build", 14 | "build:lib": "cd @noctaCrdt && pnpm build", 15 | "build:client": "cd client && pnpm build", 16 | "build:server": "cd server && pnpm build", 17 | "dev": "pnpm -r --parallel dev" 18 | }, 19 | "keywords": [], 20 | "author": "", 21 | "license": "ISC", 22 | "devDependencies": { 23 | "@noctaCrdt": "workspace:*", 24 | "@eslint/js": "^9.14.0", 25 | "@typescript-eslint/eslint-plugin": "^7.18.0", 26 | "@typescript-eslint/parser": "^7.18.0", 27 | "eslint": "^8.57.1", 28 | "eslint-plugin-jsx-a11y": "^6.8.0", 29 | "eslint-plugin-react": "^7.33.2", 30 | "eslint-plugin-react-hooks": "^4.6.0", 31 | "eslint-config-airbnb": "^19.0.4", 32 | "eslint-config-airbnb-typescript": "^18.0.0", 33 | "eslint-config-prettier": "^9.0.0", 34 | "eslint-plugin-import": "^2.29.1", 35 | "eslint-plugin-prettier": "^5.0.0", 36 | "eslint-import-resolver-typescript": "^3.6.3", 37 | "prettier": "^3.0.0", 38 | "typescript": "~5.3.3" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "@noctaCrdt" 3 | - "client" 4 | - "server" 5 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | /build 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | pnpm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | 15 | # OS 16 | .DS_Store 17 | 18 | # Tests 19 | /coverage 20 | /.nyc_output 21 | 22 | # IDEs and editors 23 | /.idea 24 | .project 25 | .classpath 26 | .c9/ 27 | *.launch 28 | .settings/ 29 | *.sublime-workspace 30 | 31 | # IDE - VSCode 32 | .vscode/* 33 | !.vscode/settings.json 34 | !.vscode/tasks.json 35 | !.vscode/launch.json 36 | !.vscode/extensions.json 37 | 38 | # dotenv environment variable files 39 | .env 40 | .env.development.local 41 | .env.test.local 42 | .env.production.local 43 | .env.local 44 | 45 | # temp directory 46 | .temp 47 | .tmp 48 | 49 | # Runtime data 50 | pids 51 | *.pid 52 | *.seed 53 | *.pid.lock 54 | 55 | # Diagnostic reports (https://nodejs.org/api/report.html) 56 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 57 | -------------------------------------------------------------------------------- /server/Dockerfile: -------------------------------------------------------------------------------- 1 | # 1. Node.js 20 기반 이미지 2 | FROM node:20 3 | WORKDIR /app 4 | 5 | # 2. 모노레포 루트에서 필요한 파일 복사 6 | COPY ./package.json ./pnpm-lock.yaml ./pnpm-workspace.yaml ./ 7 | COPY ./server/package.json ./server/ 8 | COPY ./@noctaCrdt/package.json ./@noctaCrdt/ 9 | 10 | # 3. pnpm 설치 및 의존성 설치 11 | RUN npm install -g pnpm 12 | RUN pnpm install 13 | 14 | # 4. 애플리케이션 소스 복사 및 빌드 15 | COPY . . 16 | RUN pnpm --filter server run build 17 | 18 | # 5. NestJS 서버 실행 19 | EXPOSE 3000 20 | CMD ["pnpm", "--filter", "server", "run", "start:prod"] -------------------------------------------------------------------------------- /server/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | # 1. Node.js 20 기반 이미지 2 | FROM node:20 3 | WORKDIR /app 4 | 5 | # 2. 모노레포 루트에서 필요한 파일 복사 6 | COPY ./package.json ./pnpm-lock.yaml ./pnpm-workspace.yaml ./ 7 | COPY ./server/package.json ./server/ 8 | 9 | # 3. pnpm 설치 및 의존성 설치 10 | RUN npm install -g pnpm 11 | RUN pnpm install 12 | 13 | # 4. 소스 코드 복사 14 | COPY . . 15 | 16 | # 5. NestJS 개발 서버 실행용 포트 노출 17 | EXPOSE 3000 18 | 19 | # 6. 개발 모드 실행 20 | CMD ["pnpm", "--filter", "server", "run", "start:dev"] -------------------------------------------------------------------------------- /server/eslint.config.js: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from "url"; 2 | import { dirname, resolve } from "path"; 3 | import rootConfig from "../eslint.config.js"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | /** @type {import('eslint').Linter.FlatConfig[]} */ 9 | export default [ 10 | ...rootConfig, 11 | 12 | { 13 | files: ["src/**/*.ts", "test/**/*.ts"], 14 | languageOptions: { 15 | parserOptions: { 16 | project: resolve(__dirname, "./tsconfig.json"), 17 | }, 18 | globals: { 19 | // Test globals 20 | describe: true, 21 | it: true, 22 | expect: true, 23 | beforeEach: true, 24 | afterEach: true, 25 | beforeAll: true, 26 | afterAll: true, 27 | jest: true, 28 | // Node globals 29 | process: true, 30 | module: true, 31 | require: true, 32 | __dirname: true, 33 | __filename: true, 34 | }, 35 | }, 36 | rules: { 37 | "no-console": ["warn", { allow: ["warn", "error", "info"] }], 38 | "@typescript-eslint/interface-name-prefix": "off", 39 | "@typescript-eslint/explicit-function-return-type": "off", 40 | "@typescript-eslint/explicit-module-boundary-types": "off", 41 | "@typescript-eslint/no-explicit-any": "warn", 42 | }, 43 | }, 44 | ]; 45 | -------------------------------------------------------------------------------- /server/jest-mongodb-config.ts: -------------------------------------------------------------------------------- 1 | const config = { 2 | mongodbMemoryServerOptions: { 3 | binary: { 4 | version: "8.0.3", // 사용할 MongoDB 버전 5 | skipMD5: true, 6 | }, 7 | autoStart: false, 8 | instance: {}, 9 | }, 10 | }; 11 | 12 | export default config; 13 | -------------------------------------------------------------------------------- /server/jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "jest"; 2 | 3 | const config: Config = { 4 | moduleFileExtensions: ["js", "json", "ts"], 5 | rootDir: ".", 6 | testRegex: ".*\\.spec\\.ts$", 7 | transform: { 8 | "^.+\\.(t|j)s$": [ 9 | "ts-jest", 10 | { 11 | tsconfig: "tsconfig.json", 12 | useESM: true, 13 | }, 14 | ], 15 | }, 16 | collectCoverageFrom: ["**/*.(t|j)s"], 17 | coverageDirectory: "./coverage", 18 | testEnvironment: "node", 19 | preset: "@shelf/jest-mongodb", 20 | watchPathIgnorePatterns: ["globalConfig"], 21 | transformIgnorePatterns: ["/node_modules/(?!(nanoid)/)", "/node_modules/(?!@noctaCrdt)"], 22 | extensionsToTreatAsEsm: [".ts"], 23 | moduleNameMapper: { 24 | "^@noctaCrdt$": "/../@noctaCrdt/dist/Crdt.js", 25 | "^@noctaCrdt/(.*)$": "/../@noctaCrdt/dist/$1.js", 26 | "^nanoid$": require.resolve("nanoid"), 27 | }, 28 | }; 29 | 30 | export default config; 31 | -------------------------------------------------------------------------------- /server/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true, 7 | "webpack": true, 8 | "tsConfigPath": "tsconfig.json" 9 | }, 10 | "projects": { 11 | "crdt": { 12 | "type": "library", 13 | "root": "../@noctaCrdt", 14 | "entryFile": "Crdt", 15 | "sourceRoot": "../@noctaCrdt", 16 | "compilerOptions": { 17 | "tsConfigPath": "../@noctaCrdt/tsconfig.json" 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "type": "commonjs", 9 | "scripts": { 10 | "build": " pnpm build:noctaCrdt && nest build --webpack --webpackPath webpack.config.js", 11 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 12 | "start": "nest start", 13 | "start:dev": "nest start --watch", 14 | "dev": "nest start --watch", 15 | "start:debug": "nest start --debug --watch", 16 | "start:prod": "node dist/main", 17 | "lint": "eslint \"src/**/*.{ts,tsx}\" --fix", 18 | "build:noctaCrdt": "pnpm --filter @noctaCrdt build", 19 | "test": "pnpm build:noctaCrdt && jest", 20 | "test:watch": "jest --watch", 21 | "test:cov": "jest --coverage", 22 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 23 | "test:e2e": "jest --config ./test/jest-e2e.json" 24 | }, 25 | "dependencies": { 26 | "@nestjs/common": "^10.0.0", 27 | "@nestjs/config": "^3.3.0", 28 | "@nestjs/core": "^10.0.0", 29 | "@nestjs/jwt": "^10.2.0", 30 | "@nestjs/mongoose": "^10.1.0", 31 | "@nestjs/passport": "^10.0.3", 32 | "@nestjs/platform-express": "^10.0.0", 33 | "@nestjs/platform-socket.io": "^10.4.7", 34 | "@nestjs/swagger": "^8.0.7", 35 | "@nestjs/websockets": "^10.4.7", 36 | "@noctaCrdt": "workspace:*", 37 | "@types/cookie-parser": "^1.4.7", 38 | "bcrypt": "^5.1.1", 39 | "cookie-parser": "^1.4.7", 40 | "express": "^4.21.1", 41 | "mongodb-memory-server": "^10.1.2", 42 | "mongoose": "^8.8.0", 43 | "nanoid": "^3.0.0", 44 | "passport": "^0.7.0", 45 | "passport-jwt": "^4.0.1", 46 | "reflect-metadata": "^0.2.0", 47 | "rxjs": "^7.8.1", 48 | "socket.io": "^4.8.1" 49 | }, 50 | "devDependencies": { 51 | "@nestjs/cli": "^10.0.0", 52 | "@nestjs/schematics": "^10.0.0", 53 | "@nestjs/testing": "^10.0.0", 54 | "@shelf/jest-mongodb": "^4.3.2", 55 | "@types/express": "^5.0.0", 56 | "@types/jest": "^29.5.2", 57 | "@types/node": "^20.3.1", 58 | "@types/supertest": "^6.0.0", 59 | "jest": "^29.5.0", 60 | "source-map-support": "^0.5.21", 61 | "supertest": "^7.0.0", 62 | "ts-jest": "^29.1.0", 63 | "ts-loader": "^9.4.3", 64 | "ts-node": "^10.9.1", 65 | "tsconfig-paths": "^4.2.0" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /server/src/@types/express/index.d.ts: -------------------------------------------------------------------------------- 1 | import "express"; 2 | 3 | declare module "express" { 4 | export interface Request { 5 | user?: User; 6 | cookies: { [key: string]: string }; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /server/src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from "@nestjs/testing"; 2 | import { AppController } from "./app.controller"; 3 | import { AppService } from "./app.service"; 4 | 5 | describe("AppController", () => { 6 | let appController: AppController; 7 | 8 | beforeEach(async () => { 9 | const app: TestingModule = await Test.createTestingModule({ 10 | controllers: [AppController], 11 | providers: [AppService], 12 | }).compile(); 13 | 14 | appController = app.get(AppController); 15 | }); 16 | 17 | describe("root", () => { 18 | it('should return "Hello World!"', () => { 19 | expect(appController.getHello()).toBe("Hello World!"); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /server/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from "@nestjs/common"; 2 | import { ApiTags, ApiOperation, ApiResponse } from "@nestjs/swagger"; 3 | import { AppService } from "./app.service"; 4 | 5 | @ApiTags("app") 6 | @Controller() 7 | export class AppController { 8 | constructor(private readonly appService: AppService) {} 9 | 10 | @Get() 11 | @ApiOperation({ summary: "Get a greeting message" }) 12 | @ApiResponse({ status: 200, description: "Successfully retrieved greeting message." }) 13 | public getHello(): string { 14 | return this.appService.getHello(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /server/src/app.module.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from "@nestjs/testing"; 2 | import { MongooseModule } from "@nestjs/mongoose"; 3 | import mongoose from "mongoose"; 4 | import { AppModule } from "./app.module"; 5 | 6 | jest.setTimeout(20000); 7 | 8 | jest.mock("nanoid", () => ({ 9 | nanoid: () => "mockNanoId123", 10 | })); 11 | 12 | describe("AppModule", () => { 13 | let testingModule: TestingModule; 14 | 15 | beforeAll(async () => { 16 | // jest-mongodb가 설정한 MONGO_URL을 MONGO_URI로 설정 17 | process.env.MONGO_URI = process.env.MONGO_URL || "mongodb://localhost:27017/test"; 18 | if (!process.env.JWT_SECRET) { 19 | process.env.JWT_SECRET = "test-secret"; 20 | } 21 | if (!process.env.JWT_REFRESH_SECRET) { 22 | process.env.JWT_REFRESH_SECRET = "test-secret"; 23 | } 24 | 25 | testingModule = await Test.createTestingModule({ 26 | imports: [MongooseModule.forRoot(process.env.MONGO_URI), AppModule], 27 | }).compile(); 28 | 29 | await mongoose.connect(process.env.MONGO_URI); 30 | }); 31 | 32 | afterAll(async () => { 33 | await mongoose.connection.close(); 34 | if (testingModule) { 35 | await testingModule.close(); 36 | } 37 | }); 38 | 39 | it("should connect to the MongoDB instance provided by jest-mongodb", async () => { 40 | expect(mongoose.connection.readyState).toBe(1); // 연결 상태가 'connected'인지 확인 41 | }); 42 | 43 | it("should load AppModule without errors", async () => { 44 | expect(AppModule).toBeDefined(); // AppModule이 정의되었는지 확인 45 | }); 46 | 47 | it("should have a valid MongoDB URI", async () => { 48 | const uri = process.env.MONGO_URI; 49 | expect(uri).toBeDefined(); 50 | expect(uri).toMatch(/^mongodb:\/\/.+/); // MongoDB URI 형식인지 확인 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /server/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { AppController } from "./app.controller"; 3 | import { AppService } from "./app.service"; 4 | import { ConfigModule, ConfigService } from "@nestjs/config"; 5 | import { MongooseModule } from "@nestjs/mongoose"; 6 | import { AuthModule } from "./auth/auth.module"; 7 | import { WorkspaceModule } from "./workspace/workspace.module"; 8 | import { WorkspaceController } from "./workspace/workspace.controller"; 9 | 10 | @Module({ 11 | imports: [ 12 | // ConfigModule 설정 13 | ConfigModule.forRoot({ 14 | isGlobal: true, // 전역 모듈로 설정 15 | envFilePath: process.env.NODE_ENV === "production" ? undefined : ".env", // 배포 환경에서는 .env 파일 무시 16 | }), 17 | // MongooseModule 설정 18 | MongooseModule.forRootAsync({ 19 | imports: [ConfigModule], 20 | inject: [ConfigService], 21 | useFactory: (configService: ConfigService) => ({ 22 | uri: configService.get("MONGO_URI"), // 환경 변수에서 MongoDB URI 가져오기 23 | }), 24 | }), 25 | AuthModule, 26 | WorkspaceModule, 27 | ], 28 | controllers: [AppController, WorkspaceController], 29 | providers: [AppService], 30 | }) 31 | export class AppModule {} 32 | -------------------------------------------------------------------------------- /server/src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | 3 | @Injectable() 4 | export class AppService { 5 | getHello(): string { 6 | return "Hello World!"; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /server/src/auth/auth.interface.ts: -------------------------------------------------------------------------------- 1 | export interface IJwtPayload { 2 | sub?: string; // 사용자 ID 3 | email?: string; // 사용자 이메일 4 | iat: number; // 발급 시간 5 | exp: number; // 만료 시간 6 | } 7 | -------------------------------------------------------------------------------- /server/src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { MongooseModule } from "@nestjs/mongoose"; 3 | import { User, UserSchema } from "./schemas/user.schema"; 4 | import { AuthService } from "./auth.service"; 5 | import { AuthController } from "./auth.controller"; 6 | import { JwtModule } from "@nestjs/jwt"; 7 | import { PassportModule } from "@nestjs/passport"; 8 | import { JwtStrategy } from "./strategies/jwt.strategy"; 9 | import { JwtRefreshTokenStrategy } from "./strategies/jwt-refresh-token.strategy"; 10 | import { ConfigModule, ConfigService } from "@nestjs/config"; 11 | import { JwtAuthGuard } from "./guards/jwt-auth.guard"; 12 | import { JwtRefreshTokenAuthGuard } from "./guards/jwt-refresh-token-auth.guard"; 13 | 14 | @Module({ 15 | imports: [ 16 | MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]), 17 | PassportModule, 18 | JwtModule.registerAsync({ 19 | global: true, 20 | imports: [ConfigModule], 21 | inject: [ConfigService], 22 | useFactory: (config: ConfigService) => ({ 23 | secret: config.get("JWT_SECRET"), 24 | signOptions: { expiresIn: "1h" }, 25 | }), 26 | }), 27 | ], 28 | exports: [AuthService, JwtModule], 29 | providers: [ 30 | AuthService, 31 | JwtStrategy, 32 | JwtRefreshTokenStrategy, 33 | JwtAuthGuard, 34 | JwtRefreshTokenAuthGuard, 35 | ], 36 | controllers: [AuthController], 37 | }) 38 | export class AuthModule {} 39 | -------------------------------------------------------------------------------- /server/src/auth/dto/user.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | 3 | export class UserDto { 4 | @ApiProperty({ 5 | description: "The unique identifier of the user", 6 | example: "5f8f8c44b54764421b7156c9", 7 | }) 8 | id: string; 9 | 10 | @ApiProperty({ 11 | description: "The email of the user", 12 | example: "example@email.com", 13 | }) 14 | email: string; 15 | 16 | @ApiProperty({ 17 | description: "The name of the user", 18 | example: "John Doe", 19 | }) 20 | name: string; 21 | 22 | @ApiProperty({ 23 | description: "The access token for authentication", 24 | example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", 25 | }) 26 | accessToken?: string; 27 | } 28 | -------------------------------------------------------------------------------- /server/src/auth/guards/jwt-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, ExecutionContext, UnauthorizedException } from "@nestjs/common"; 2 | import { AuthGuard } from "@nestjs/passport"; 3 | 4 | @Injectable() 5 | export class JwtAuthGuard extends AuthGuard("jwt") { 6 | constructor() { 7 | super(); 8 | } 9 | 10 | async canActivate(context: ExecutionContext): Promise { 11 | const request = context.switchToHttp().getRequest(); 12 | const token = request.headers.authorization?.split(" ")[1]; 13 | 14 | if (!token) { 15 | throw new UnauthorizedException("Authorization header not found"); 16 | } 17 | 18 | const canActivate = (await super.canActivate(context)) as boolean; 19 | 20 | return canActivate; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /server/src/auth/guards/jwt-refresh-token-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, ExecutionContext, UnauthorizedException } from "@nestjs/common"; 2 | import { AuthGuard } from "@nestjs/passport"; 3 | import { AuthService } from "../auth.service"; 4 | 5 | @Injectable() 6 | export class JwtRefreshTokenAuthGuard extends AuthGuard("jwt-refresh") { 7 | constructor(private readonly authService: AuthService) { 8 | super(); 9 | } 10 | 11 | async canActivate(context: ExecutionContext): Promise { 12 | // Refresh Token 유효성 인증 13 | const canActivate = (await super.canActivate(context)) as boolean; 14 | 15 | const request = context.switchToHttp().getRequest(); 16 | 17 | // 사용자에게 등록된 Refresh Token와 일치 여부 확인 18 | const { refreshToken } = request.cookies; 19 | const isValid = await this.authService.validateRefreshToken(refreshToken); 20 | if (!isValid) { 21 | throw new UnauthorizedException("Invalid refresh token"); 22 | } 23 | 24 | return canActivate; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /server/src/auth/schemas/user.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; 2 | import { Document } from "mongoose"; 3 | import { nanoid } from "nanoid"; 4 | 5 | export type UserDocument = User & Document; 6 | 7 | @Schema() 8 | export class User { 9 | @Prop({ required: true, unique: true, default: () => nanoid() }) 10 | id: string; 11 | 12 | @Prop({ required: true, unique: true }) 13 | email: string; 14 | 15 | @Prop({ required: true }) 16 | password: string; 17 | 18 | @Prop({ required: true }) 19 | name: string; 20 | 21 | @Prop() 22 | refreshToken: string; 23 | 24 | @Prop({ type: [String], default: [] }) 25 | workspaces: string[]; 26 | } 27 | 28 | export const UserSchema = SchemaFactory.createForClass(User); 29 | -------------------------------------------------------------------------------- /server/src/auth/strategies/jwt-refresh-token.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from "@nestjs/common"; 2 | import { PassportStrategy } from "@nestjs/passport"; 3 | import { ExtractJwt, Strategy } from "passport-jwt"; 4 | import { AuthService } from "../auth.service"; 5 | import { Request as ExpressRequest } from "express"; 6 | 7 | @Injectable() 8 | export class JwtRefreshTokenStrategy extends PassportStrategy(Strategy, "jwt-refresh") { 9 | constructor(private readonly authService: AuthService) { 10 | super({ 11 | jwtFromRequest: ExtractJwt.fromExtractors([ 12 | (req: ExpressRequest) => { 13 | let token = null; 14 | if (req && req.cookies) { 15 | token = req.cookies["refreshToken"]; 16 | } 17 | return token; 18 | }, 19 | ]), 20 | ignoreExpiration: false, 21 | secretOrKey: process.env.JWT_REFRESH_SECRET, 22 | passReqToCallback: true, 23 | }); 24 | } 25 | 26 | async validate(req: ExpressRequest) { 27 | const { refreshToken } = req.cookies; 28 | if (!refreshToken) { 29 | throw new UnauthorizedException(); 30 | } 31 | 32 | const user = await this.authService.findByRefreshToken(refreshToken); 33 | if (!user) { 34 | throw new UnauthorizedException(); 35 | } 36 | 37 | return user; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /server/src/auth/strategies/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from "@nestjs/common"; 2 | import { PassportStrategy } from "@nestjs/passport"; 3 | import { ExtractJwt, Strategy } from "passport-jwt"; 4 | import { AuthService } from "../auth.service"; 5 | import { IJwtPayload } from "../auth.interface"; 6 | 7 | @Injectable() 8 | export class JwtStrategy extends PassportStrategy(Strategy) { 9 | constructor(private readonly authService: AuthService) { 10 | super({ 11 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 12 | ignoreExpiration: false, 13 | secretOrKey: process.env.JWT_SECRET, 14 | }); 15 | } 16 | 17 | async validate(payload: IJwtPayload) { 18 | const user = await this.authService.findById(payload.sub); 19 | if (!user) { 20 | throw new UnauthorizedException(); 21 | } 22 | return user; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /server/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from "@nestjs/core"; 2 | import { AppModule } from "./app.module"; 3 | import { createSwaggerDocument } from "./swagger/swagger.config"; 4 | import cookieParser from "cookie-parser"; 5 | 6 | const bootstrap = async () => { 7 | const app = await NestFactory.create(AppModule); 8 | 9 | const isDevelopment = process.env.NODE_ENV === "development"; 10 | 11 | const allowedOrigins = ["https://nocta.site", "https://www.nocta.site"]; 12 | 13 | app.enableCors({ 14 | origin: (origin, callback) => { 15 | if (isDevelopment || !origin || allowedOrigins.includes(origin)) { 16 | callback(null, true); 17 | } else { 18 | callback(new Error("Not allowed by CORS")); 19 | } 20 | }, 21 | credentials: true, // 쿠키 전송을 위해 필수 22 | exposedHeaders: ["Authorization"], 23 | }); 24 | 25 | app.use(cookieParser()); 26 | app.setGlobalPrefix("api"); 27 | 28 | createSwaggerDocument(app); 29 | 30 | await app.listen(process.env.PORT ?? 3000); 31 | }; 32 | bootstrap(); 33 | -------------------------------------------------------------------------------- /server/src/swagger/swagger.config.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from "@nestjs/common"; 2 | import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger"; 3 | 4 | export const createSwaggerDocument = (app: INestApplication): void => { 5 | if (process.env.NODE_ENV !== "development") { 6 | return; // 개발 환경이 아니면 Swagger 비활성화 7 | } 8 | 9 | const config = new DocumentBuilder() 10 | .setTitle("Nocta API Docs") 11 | .setDescription("Nocta API description") 12 | .setVersion("1.0.0") 13 | .addBearerAuth() 14 | .addCookieAuth("refreshToken") 15 | .build(); 16 | 17 | const document = SwaggerModule.createDocument(app, config); 18 | SwaggerModule.setup("api-docs", app, document); 19 | }; 20 | -------------------------------------------------------------------------------- /server/src/workspace/workspace.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from "@nestjs/testing"; 2 | import { WorkspaceController } from "./workspace.controller"; 3 | import { WorkSpaceService } from "./workspace.service"; 4 | import { JwtAuthGuard } from "../auth/guards/jwt-auth.guard"; 5 | import { ExecutionContext } from "@nestjs/common"; 6 | 7 | describe("WorkspaceController", () => { 8 | let controller: WorkspaceController; 9 | 10 | beforeEach(async () => { 11 | const mockJwtAuthGuard = { 12 | canActivate: (context: ExecutionContext) => true, 13 | }; 14 | 15 | const module: TestingModule = await Test.createTestingModule({ 16 | controllers: [WorkspaceController], 17 | providers: [ 18 | { 19 | provide: WorkSpaceService, 20 | useValue: {}, // 필요한 경우 모의 서비스 구현 21 | }, 22 | ], 23 | }) 24 | .overrideGuard(JwtAuthGuard) 25 | .useValue(mockJwtAuthGuard) 26 | .compile(); 27 | 28 | controller = module.get(WorkspaceController); 29 | }); 30 | 31 | it("should be defined", () => { 32 | expect(controller).toBeDefined(); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /server/src/workspace/workspace.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Post, Delete, Get, Body, Req, UseGuards } from "@nestjs/common"; 2 | import { WorkSpaceService } from "./workspace.service"; 3 | import { ApiTags } from "@nestjs/swagger"; 4 | import { JwtAuthGuard } from "../auth/guards/jwt-auth.guard"; 5 | import { Request as ExpressRequest } from "express"; 6 | import { Workspace } from "./schemas/workspace.schema"; 7 | import { WorkspaceListItem } from "@noctaCrdt/Interfaces"; 8 | 9 | @ApiTags("workspace") 10 | @UseGuards(JwtAuthGuard) 11 | @Controller("workspace") 12 | export class WorkspaceController { 13 | constructor(private readonly workspaceService: WorkSpaceService) {} 14 | 15 | // 워크스페이스 생성 16 | @Post("create") 17 | async createWorkspace( 18 | @Req() req: ExpressRequest, 19 | @Body("name") name?: string, 20 | ): Promise { 21 | const userId = req.user.id; 22 | return this.workspaceService.createWorkspace(userId, name); 23 | } 24 | 25 | // 워크스페이스 삭제 26 | // 삭제한 워크스페이스에서 작업중인 사람들을 내쫓기 27 | @Delete("delete") 28 | async deleteWorkspace( 29 | @Req() req: ExpressRequest, 30 | @Body("workspaceId") workspaceId: string, 31 | ): Promise { 32 | const userId = req.user.id; 33 | return this.workspaceService.deleteWorkspace(userId, workspaceId); 34 | } 35 | 36 | // 유저의 워크스페이스 목록 조회 37 | @Get("findAll") 38 | async getUserWorkspaces(@Req() req: ExpressRequest): Promise { 39 | const userId = req.user.id; 40 | return this.workspaceService.getUserWorkspaces(userId); 41 | } 42 | 43 | // 워크스페이스에 유저 초대 44 | @Post("invite") 45 | async inviteUser( 46 | @Req() req: ExpressRequest, 47 | @Body("workspaceId") workspaceId: string, 48 | @Body("invitedUserId") invitedUserId: string, 49 | ): Promise { 50 | const ownerId = req.user.id; 51 | return this.workspaceService.inviteUserToWorkspace(ownerId, workspaceId, invitedUserId); 52 | } 53 | 54 | // 소켓에 관여해야 하는 부분 55 | // 삭제할 워크스페이스에 있는 사람들 내쫓기 56 | // 워크스페이스 권한이 생기면 접속중인 사람에게 알리기 57 | } 58 | -------------------------------------------------------------------------------- /server/src/workspace/workspace.interface.ts: -------------------------------------------------------------------------------- 1 | export interface WorkspaceInviteData { 2 | email: string; 3 | workspaceId: string; 4 | } 5 | -------------------------------------------------------------------------------- /server/src/workspace/workspace.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { WorkSpaceService } from "./workspace.service"; 3 | import { MongooseModule } from "@nestjs/mongoose"; 4 | import { Workspace, WorkspaceSchema } from "./schemas/workspace.schema"; 5 | import { WorkspaceGateway } from "./workspace.gateway"; 6 | import { AuthModule } from "../auth/auth.module"; 7 | import { JwtAuthGuard } from "../auth/guards/jwt-auth.guard"; 8 | import { JwtModule } from "@nestjs/jwt"; 9 | import { ConfigModule, ConfigService } from "@nestjs/config"; 10 | import { JwtStrategy } from "../auth/strategies/jwt.strategy"; 11 | import { JwtRefreshTokenStrategy } from "../auth/strategies/jwt-refresh-token.strategy"; 12 | import { JwtRefreshTokenAuthGuard } from "../auth/guards/jwt-refresh-token-auth.guard"; 13 | import { User, UserSchema } from "../auth/schemas/user.schema"; 14 | 15 | @Module({ 16 | imports: [ 17 | AuthModule, 18 | MongooseModule.forFeature([ 19 | { name: Workspace.name, schema: WorkspaceSchema }, 20 | { name: User.name, schema: UserSchema }, 21 | ]), 22 | JwtModule.registerAsync({ 23 | global: true, 24 | imports: [ConfigModule], 25 | inject: [ConfigService], 26 | useFactory: (config: ConfigService) => ({ 27 | secret: config.get("JWT_SECRET"), 28 | signOptions: { expiresIn: "1h" }, 29 | }), 30 | }), 31 | ], 32 | exports: [WorkSpaceService, JwtModule], 33 | providers: [ 34 | WorkSpaceService, 35 | WorkspaceGateway, 36 | JwtStrategy, 37 | JwtRefreshTokenStrategy, 38 | JwtAuthGuard, 39 | JwtRefreshTokenAuthGuard, 40 | ], 41 | }) 42 | export class WorkspaceModule {} 43 | -------------------------------------------------------------------------------- /server/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from "@nestjs/testing"; 2 | import { INestApplication } from "@nestjs/common"; 3 | import request from "supertest"; 4 | import { AppModule } from "./../src/app.module"; 5 | 6 | describe("AppController (e2e)", () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it("/ (GET)", () => { 19 | return request(app.getHttpServer()).get("/").expect(200).expect("Hello World!"); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /server/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /server/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "references": [{ "path": "../@noctaCrdt" }], 4 | "compilerOptions": { 5 | "module": "ESNext", 6 | "declaration": true, 7 | "removeComments": true, 8 | "emitDecoratorMetadata": true, 9 | "experimentalDecorators": true, 10 | "allowSyntheticDefaultImports": true, 11 | "target": "ES2021", 12 | "sourceMap": true, 13 | "outDir": "./dist", 14 | "baseUrl": "./", 15 | "incremental": true, 16 | 17 | "strict": false, 18 | "skipLibCheck": true, 19 | "strictNullChecks": false, 20 | "noImplicitAny": false, 21 | "strictBindCallApply": false, 22 | "paths": { 23 | "@noctaCrdt": ["../@noctaCrdt/dist"], 24 | "@noctaCrdt/*": ["../@noctaCrdt/dist/*"] 25 | }, 26 | "forceConsistentCasingInFileNames": false, 27 | "noFallthroughCasesInSwitch": false, 28 | 29 | "moduleResolution": "node", 30 | "esModuleInterop": true, 31 | "resolveJsonModule": true, 32 | 33 | "allowJs": true 34 | }, 35 | "include": ["src/**/*", "test/**/*", "schemas"], 36 | "exclude": ["node_modules", "dist"] 37 | } 38 | -------------------------------------------------------------------------------- /server/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | module.exports = { 4 | mode: "development", 5 | resolve: { 6 | extensions: [".ts", ".js"], 7 | alias: { 8 | "@noctaCrdt": path.resolve(__dirname, "../@noctaCrdt/dist"), 9 | }, 10 | }, 11 | module: { 12 | rules: [ 13 | { 14 | test: /\.ts$/, 15 | use: { 16 | loader: "ts-loader", 17 | options: { 18 | configFile: "tsconfig.json", 19 | }, 20 | }, 21 | exclude: /node_modules/, 22 | }, 23 | ], 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | // tsconfig.base.json 2 | { 3 | "compilerOptions": { 4 | "target": "ES2021", 5 | "module": "ESNext", 6 | "declaration": true, 7 | "removeComments": true, 8 | "emitDecoratorMetadata": true, 9 | "experimentalDecorators": true, 10 | "allowSyntheticDefaultImports": true, 11 | "sourceMap": true, 12 | "baseUrl": ".", 13 | "skipLibCheck": true, 14 | "strict": true, 15 | "forceConsistentCasingInFileNames": true 16 | } 17 | } 18 | --------------------------------------------------------------------------------