├── .eslintignore ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ └── issue-template.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── DEPLOY.yml │ ├── FRONTEND_PR_CHECK.yml │ ├── REVIEW_REQUEST_ALERT.yml │ └── reviewers.json ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .prettierrc ├── README.md ├── commitlint.config.mjs ├── docker-compose.override.yml ├── docker-compose.prod.yml ├── docker-compose.yml ├── eslint.config.mjs ├── example ├── .env.example └── docker-compose.db.yml ├── package.json ├── packages ├── .env.example ├── backend │ ├── .dockerignore │ ├── .gitignore │ ├── .prettierrc │ ├── Dockerfile │ ├── README.md │ ├── nest-cli.json │ ├── package.json │ ├── src │ │ ├── app.module.ts │ │ ├── collaborative │ │ │ ├── collaborative.module.ts │ │ │ ├── collaborative.service.ts │ │ │ └── collaborative.type.ts │ │ ├── common │ │ │ ├── config │ │ │ │ ├── mongo.config.ts │ │ │ │ └── typeorm.config.ts │ │ │ ├── constants │ │ │ │ ├── error.message.constants.ts │ │ │ │ ├── space.constants.ts │ │ │ │ └── websocket.constants.ts │ │ │ ├── filters │ │ │ │ └── all-exceptions.filter.ts │ │ │ └── utils │ │ │ │ └── socket.util.ts │ │ ├── env.d.ts │ │ ├── main.ts │ │ ├── note │ │ │ ├── dto │ │ │ │ └── note.dto.ts │ │ │ ├── note.controller.ts │ │ │ ├── note.module.ts │ │ │ ├── note.schema.ts │ │ │ └── note.service.ts │ │ ├── space │ │ │ ├── dto │ │ │ │ ├── create.space.dto.ts │ │ │ │ └── update.space.dto.ts │ │ │ ├── space.controller.spec.ts │ │ │ ├── space.controller.ts │ │ │ ├── space.module.ts │ │ │ ├── space.schema.ts │ │ │ ├── space.service.spec.ts │ │ │ ├── space.service.ts │ │ │ ├── space.validation.service.spec.ts │ │ │ └── space.validation.service.ts │ │ ├── test │ │ │ ├── mock │ │ │ │ ├── mock.constants.ts │ │ │ │ ├── note.mock.data.ts │ │ │ │ └── space.mock.data.ts │ │ │ ├── note.test.entity.ts │ │ │ ├── space.test.entity.ts │ │ │ ├── test.controller.ts │ │ │ ├── test.module.ts │ │ │ ├── test.service.ts │ │ │ └── types │ │ │ │ └── performance.types.ts │ │ └── yjs │ │ │ ├── yjs.gateway.spec.ts │ │ │ ├── yjs.gateway.ts │ │ │ └── yjs.module.ts │ ├── test │ │ ├── app.e2e-spec.ts │ │ └── jest-e2e.json │ ├── tsconfig.build.json │ └── tsconfig.json ├── frontend │ ├── .dockerignore │ ├── .gitignore │ ├── .prettierrc │ ├── .storybook │ │ ├── main.ts │ │ └── preview.ts │ ├── Dockerfile │ ├── components.json │ ├── eslint.config.js │ ├── index.html │ ├── nginx.conf │ ├── package.json │ ├── postcss.config.js │ ├── public │ │ ├── PretendardVariable.woff2 │ │ ├── favicon.svg │ │ ├── home-bg.svg │ │ └── og-image.png │ ├── src │ │ ├── App.css │ │ ├── App.tsx │ │ ├── api │ │ │ ├── constants.ts │ │ │ ├── http.ts │ │ │ ├── note.ts │ │ │ └── space.ts │ │ ├── assets │ │ │ ├── error-logo.svg │ │ │ ├── logo.svg │ │ │ └── shapes.ts │ │ ├── components │ │ │ ├── Edge.tsx │ │ │ ├── ErrorSection.tsx │ │ │ ├── Node.tsx │ │ │ ├── PointerCursor.tsx │ │ │ ├── PointerLayer.tsx │ │ │ ├── SpaceBreadcrumb.tsx │ │ │ ├── SpaceShareAlertContent.tsx │ │ │ ├── SpaceUsersIndicator.tsx │ │ │ ├── note │ │ │ │ ├── Block.tsx │ │ │ │ ├── Editor.css │ │ │ │ └── Editor.tsx │ │ │ ├── space │ │ │ │ ├── GooeyConnection.tsx │ │ │ │ ├── GooeyNode.tsx │ │ │ │ ├── InteractionGuide.tsx │ │ │ │ ├── NearNodeIndicator.tsx │ │ │ │ ├── PaletteMenu.tsx │ │ │ │ ├── SpaceNode.stories.tsx │ │ │ │ ├── SpaceNode.tsx │ │ │ │ ├── SpacePageHeader.tsx │ │ │ │ ├── SpaceView.tsx │ │ │ │ ├── YjsSpaceView.tsx │ │ │ │ └── context-menu │ │ │ │ │ ├── CustomContextMenu.tsx │ │ │ │ │ ├── SpaceContextMenuWrapper.tsx │ │ │ │ │ └── type.ts │ │ │ └── ui │ │ │ │ ├── breadcrumb.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── context-menu.tsx │ │ │ │ ├── dialog.tsx │ │ │ │ ├── dropdown-menu.tsx │ │ │ │ ├── hover-card.tsx │ │ │ │ ├── input.tsx │ │ │ │ └── label.tsx │ │ ├── hooks │ │ │ ├── useAutofit.ts │ │ │ ├── useDragNode.ts │ │ │ ├── useMilkdownCollab.ts │ │ │ ├── useMilkdownEditor.ts │ │ │ ├── useMoveNode.ts │ │ │ ├── useSpaceSelection.ts │ │ │ ├── useYjsSpace.tsx │ │ │ ├── useYjsSpaceAwareness.ts │ │ │ ├── useZoomSpace.ts │ │ │ └── yjs │ │ │ │ ├── useY.ts │ │ │ │ ├── useYjsAwareness.ts │ │ │ │ └── useYjsConnection.tsx │ │ ├── index.css │ │ ├── lib │ │ │ ├── milkdown-plugin-placeholder.ts │ │ │ ├── polyfill-relative-urls-websocket.ts │ │ │ ├── prompt-dialog.tsx │ │ │ └── utils.ts │ │ ├── main.tsx │ │ ├── pages │ │ │ ├── Home.tsx │ │ │ ├── NotFound.tsx │ │ │ └── Space.tsx │ │ ├── store │ │ │ └── yjs.ts │ │ └── vite-env.d.ts │ ├── tailwind.config.js │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts └── shared │ ├── package.json │ ├── tsconfig.json │ └── types │ └── index.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | # Backend 2 | /packages/backend/* 3 | /packages/backend/**/* 4 | 5 | # Build outputs 6 | **/dist 7 | **/build 8 | **/coverage 9 | 10 | # Dependencies 11 | **/node_modules -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @boostcampwm-2024/web29 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue-template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Issue Template 3 | about: "(Default Template)" 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 🔍 이슈 설명 11 | 12 | 13 | ## ✅ 인수 조건 14 | 15 | 16 | ## 🚜 작업 사항 17 | - [ ] 18 | 19 | ## 📌 참고 자료 20 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | > [!NOTE] 2 | > PR 작성 전 체크 리스트 3 | > - Assignees에 PR 작성자를 추가해 주세요. 4 | > - PR에 라벨과 프로젝트를 설정해 주세요. 5 | > - PR 생성 후 2명의 리뷰어가 잘 설정되었는지 확인해 주세요. 6 | 7 | **** 8 | 9 | --- 절취선 --- 10 | 11 | ## ✏️ 한 줄 설명 12 | > 이 PR의 주요 변경 사항이나 구현된 내용을 간략히 설명해 주세요. 13 | 14 | 15 | ## ✅ 작업 내용 16 | - 17 | 18 | ## 🏷️ 관련 이슈 19 | - 20 | 21 | ## 📸 스크린샷/영상 22 | > 이번 PR에서 변경되거나 추가된 뷰가 있는 경우 이미지나 동작 영상을 첨부해 주세요. 23 | 24 | 25 | ## 📌 리뷰 진행 시 참고 사항 26 | > 리뷰 코멘트 작성 시 특정 사실에 대해 짚는 것이 아니라 코드에 대한 의견을 제안할 경우, 강도를 함께 제시해주세요! (1점: 가볍게 참고해봐도 좋을듯 ↔ 5점: 꼭 바꾸는 게 좋을 것 같음!) -------------------------------------------------------------------------------- /.github/workflows/DEPLOY.yml: -------------------------------------------------------------------------------- 1 | name: deploy 2 | on: 3 | push: 4 | branches: [dev] 5 | jobs: 6 | deploy: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: 코드 체크아웃 10 | uses: actions/checkout@v3 11 | 12 | - name: 타임스탬프 생성 13 | id: timestamp 14 | run: echo "timestamp=$(date +'%Y%m%d%H%M%S')" >> $GITHUB_OUTPUT 15 | 16 | - name: SSH로 서버 접속 및 배포 17 | uses: appleboy/ssh-action@v0.1.6 18 | env: 19 | ENV_FILE_CONTENTS: ${{ secrets.ENV_FILE_CONTENTS }} 20 | with: 21 | host: ${{ secrets.SERVER_HOST }} 22 | username: ${{ secrets.SERVER_USER }} 23 | key: ${{ secrets.SSH_KEY }} 24 | port: ${{ secrets.SERVER_SSH_PORT }} 25 | envs: ENV_FILE_CONTENTS 26 | script: | 27 | # 작업 디렉토리 생성 및 이동 28 | DEPLOY_DIR="/home/${{ secrets.SERVER_USER }}/deploy" 29 | TIMESTAMP="${{ steps.timestamp.outputs.timestamp }}" 30 | RELEASE_DIR="$DEPLOY_DIR/releases/$TIMESTAMP" 31 | 32 | mkdir -p $RELEASE_DIR 33 | cd $RELEASE_DIR 34 | 35 | # 코드 복제 36 | git clone -b dev https://github.com/boostcampwm-2024/web29-honeyflow.git . 37 | 38 | # 환경변수 파일 생성 39 | echo "$ENV_FILE_CONTENTS" > .env 40 | 41 | # 이전 컨테이너 정리 42 | docker stop db-healthcheck || true && docker rm db-healthcheck || true 43 | docker stop backend || true && docker rm backend || true 44 | docker stop frontend || true && docker rm frontend || true 45 | 46 | # 새 컨테이너 실행 47 | sudo docker compose -f docker-compose.yml -f docker-compose.prod.yml pull 48 | sudo docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d 49 | 50 | # 이전 배포 정리 (최근 3개만 유지) 51 | cd $DEPLOY_DIR/releases 52 | ls -t | tail -n +4 | xargs -I {} rm -rf {} 53 | 54 | # 사용하지 않는 Docker 이미지 정리 55 | sudo docker image prune -af 56 | -------------------------------------------------------------------------------- /.github/workflows/FRONTEND_PR_CHECK.yml: -------------------------------------------------------------------------------- 1 | name: Frontend PR Check 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | paths: 7 | - 'packages/frontend/**' 8 | - 'packages/shared/**' 9 | 10 | jobs: 11 | build-check: 12 | if: contains(github.head_ref, 'dev-fe') 13 | runs-on: ubuntu-latest 14 | 15 | # Checks API 권한 16 | permissions: 17 | checks: write 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | 22 | - uses: pnpm/action-setup@v2 23 | with: 24 | version: 9.4.0 25 | 26 | - uses: actions/setup-node@v3 27 | with: 28 | node-version: '22.9.0' 29 | cache: 'pnpm' 30 | 31 | - name: Install dependencies 32 | run: pnpm install 33 | 34 | - name: Build frontend 35 | id: build 36 | working-directory: packages/frontend 37 | run: pnpm build 38 | continue-on-error: true 39 | 40 | - name: Process Build Result 41 | uses: actions/github-script@v6 42 | with: 43 | script: | 44 | const buildOutcome = '${{ steps.build.outcome }}'; 45 | 46 | await github.rest.checks.create({ 47 | owner: context.repo.owner, 48 | repo: context.repo.name, 49 | name: 'Frontend Build', 50 | head_sha: context.sha, 51 | status: 'completed', 52 | conclusion: buildOutcome === 'success' ? 'success' : 'failure', 53 | output: { 54 | title: buildOutcome === 'success' 55 | ? '🎉 Frontend Build Successful' 56 | : '❌ Frontend Build Failed', 57 | 58 | summary: buildOutcome === 'success' 59 | ? [ 60 | '## ✅ Build Status: Success', 61 | '', 62 | '### Build Information:', 63 | '- **Build Time**: ' + new Date().toISOString(), 64 | '- **Branch**: ' + context.ref, 65 | '', 66 | '✨ Ready to be reviewed!' 67 | ].join('\n') 68 | : [ 69 | '## ❌ Build Status: Failed', 70 | '', 71 | '### Error Information:', 72 | '- **Build Time**: ' + new Date().toISOString(), 73 | '- **Branch**: ' + context.ref, 74 | '', 75 | '### Next Steps:', 76 | '1. Check the build logs for detailed error messages', 77 | '2. Fix the identified issues', 78 | '3. Push your changes to trigger a new build', 79 | '', 80 | '> Need help? Contact the frontend team.' 81 | ].join('\n'), 82 | 83 | text: buildOutcome === 'success' 84 | ? '자세한 빌드 로그는 Actions 탭에서 확인하실 수 있습니다.' 85 | : '빌드 실패 원인을 확인하시려면 위의 "Details"를 클릭하세요.' 86 | } 87 | }); 88 | 89 | if (buildOutcome === 'failure') { 90 | core.setFailed('Frontend build failed'); 91 | } -------------------------------------------------------------------------------- /.github/workflows/REVIEW_REQUEST_ALERT.yml: -------------------------------------------------------------------------------- 1 | name: BeeBot 2 | on: 3 | pull_request: 4 | types: [review_request_removed] 5 | jobs: 6 | notify_automatically_assigned_review_request: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | 11 | - name: 알림 전송 이력이 있는지 확인 12 | id: check_notification 13 | uses: actions/cache@v3 14 | with: 15 | path: .notifications 16 | key: notifications-${{ github.event.pull_request.number }} 17 | 18 | - name: 리뷰어 목록 가져오기 19 | if: steps.check_notification.outputs.cache-hit != 'true' 20 | id: reviewers 21 | uses: actions/github-script@v6 22 | with: 23 | script: | 24 | const fs = require('fs'); 25 | const workers = JSON.parse(fs.readFileSync('.github/workflows/reviewers.json')); 26 | const mention = context.payload.pull_request.requested_reviewers.map((user) => { 27 | const login = user.login; 28 | const mappedValue = workers[login]; 29 | return mappedValue ? `<@${mappedValue}>` : `No mapping found for ${login}`; 30 | }); 31 | return mention.join(', '); 32 | result-encoding: string 33 | 34 | - name: 슬랙 알림 전송 35 | if: steps.check_notification.outputs.cache-hit != 'true' 36 | uses: slackapi/slack-github-action@v1.24.0 37 | with: 38 | channel-id: ${{ secrets.SLACK_CHANNEL }} 39 | payload: | 40 | { 41 | "text": "[리뷰 요청] 새로운 PR이 등록되었습니다!", 42 | "blocks": [ 43 | { 44 | "type": "section", 45 | "text": { 46 | "type": "mrkdwn", 47 | "text": "[리뷰 요청] 새로운 PR이 등록되었습니다!\n • 제목: ${{ github.event.pull_request.title }}\n • 리뷰어: ${{ steps.reviewers.outputs.result }} \n • 링크: <${{ github.event.pull_request.html_url }}|리뷰하러 가기>" 48 | } 49 | } 50 | ] 51 | } 52 | env: 53 | SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} 54 | 55 | - name: 알림 전송 이력 생성 56 | if: steps.check_notification.outputs.cache-hit != 'true' 57 | run: | 58 | mkdir -p .notifications 59 | touch .notifications/sent 60 | -------------------------------------------------------------------------------- /.github/workflows/reviewers.json: -------------------------------------------------------------------------------- 1 | { 2 | "fru1tworld": "U07H6QPSEE7", 3 | "heegenie": "U07GSBFR0Q7", 4 | "CatyJazzy": "U07GSBRJF9D", 5 | "parkblo": "U07H6TN3WTC", 6 | "hoqn": "U07HKHTP2TT" 7 | } 8 | -------------------------------------------------------------------------------- /.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 | .eslintcache 10 | 11 | node_modules 12 | dist 13 | dist-ssr 14 | *.local 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | 27 | # env 28 | .env 29 | 30 | # develop 31 | */backend/docker.compose.yml 32 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | pnpm dlx commitlint --edit $1 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm lint-staged 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": false, 3 | "tabWidth": 2, 4 | "semi": true, 5 | "printWidth": 80, 6 | "arrowParens": "always", 7 | "jsxSingleQuote": false, 8 | "bracketSpacing": true, 9 | "trailingComma": "all", 10 | "jsxBracketSameLine": false 11 | } 12 | -------------------------------------------------------------------------------- /commitlint.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | extends: ["@commitlint/config-conventional"], 3 | }; 4 | -------------------------------------------------------------------------------- /docker-compose.override.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | mysql: 5 | image: mysql:8.0 6 | container_name: mysql-container 7 | ports: 8 | - "3306:3306" 9 | networks: 10 | - app-network 11 | environment: 12 | MYSQL_ROOT_PASSWORD: 1234 13 | MYSQL_DATABASE: dev_db 14 | MYSQL_USER: honey 15 | MYSQL_PASSWORD: 1234 16 | volumes: 17 | - mysql_data:/var/lib/mysql 18 | healthcheck: 19 | test: 20 | ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p1234"] 21 | interval: 10s 22 | timeout: 5s 23 | retries: 3 24 | command: --bind-address=0.0.0.0 25 | backend: 26 | build: 27 | context: . 28 | dockerfile: ./packages/backend/Dockerfile 29 | container_name: backend 30 | ports: 31 | - "3000:3000" 32 | - "9001:9001" 33 | depends_on: 34 | mysql: 35 | condition: service_healthy 36 | mongodb: 37 | condition: service_healthy 38 | environment: 39 | - MYSQL_HOST=mysql-container 40 | - MYSQL_PORT=3306 41 | - MYSQL_DATABASE=dev_db 42 | - MYSQL_PASSWORD=1234 43 | - MYSQL_USER=honey 44 | - NODE_ENV=dev 45 | - MONGO_HOST=mongodb-container 46 | - MONGO_USER=honey 47 | - MONGO_PASSWORD=1234 48 | - MONGO_DB=dev_db 49 | - LOG_LEVEL=info 50 | networks: 51 | - app-network 52 | 53 | frontend: 54 | build: 55 | context: . 56 | dockerfile: ./packages/frontend/Dockerfile 57 | container_name: frontend 58 | ports: 59 | - "80:80" 60 | depends_on: 61 | backend: 62 | condition: service_started 63 | networks: 64 | - app-network 65 | mongodb: 66 | image: mongo:latest 67 | container_name: mongodb-container 68 | ports: 69 | - "27017:27017" 70 | networks: 71 | - app-network 72 | environment: 73 | MONGO_INITDB_ROOT_USERNAME: honey 74 | MONGO_INITDB_ROOT_PASSWORD: 1234 75 | MONGO_INITDB_DATABASE: dev_db 76 | MONGODB_AUTH_MECHANISM: SCRAM-SHA-256 77 | command: ["mongod", "--bind_ip", "0.0.0.0"] 78 | volumes: 79 | - mongo_data:/data/db 80 | healthcheck: 81 | test: 82 | [ 83 | "CMD", 84 | "mongosh", 85 | "--username", 86 | "honey", 87 | "--password", 88 | "1234", 89 | "--eval", 90 | "db.runCommand({ ping: 1 })", 91 | ] 92 | interval: 10s 93 | timeout: 5s 94 | retries: 10 95 | 96 | volumes: 97 | mysql_data: 98 | mongo_data: 99 | 100 | networks: 101 | app-network: 102 | driver: bridge 103 | -------------------------------------------------------------------------------- /docker-compose.prod.yml: -------------------------------------------------------------------------------- 1 | services: 2 | db-healthcheck: 3 | image: curlimages/curl:latest 4 | container_name: db-healthcheck 5 | command: > 6 | /bin/sh -c " 7 | curl -f telnet://${REDIS_HOST}:${REDIS_PORT} || exit 1; 8 | curl -f telnet://${MYSQL_HOST}:${MYSQL_PORT} || exit 1; 9 | curl -f telnet://${MONGO_HOST}:27017 || exit 1; 10 | " 11 | networks: 12 | - app-network 13 | healthcheck: 14 | test: ["CMD", "/bin/sh", "-c", "exit 0"] 15 | interval: 10s 16 | timeout: 5s 17 | retries: 3 18 | 19 | backend: 20 | container_name: backend 21 | ports: 22 | - "3000:3000" 23 | build: 24 | target: production 25 | environment: 26 | # 배포 환경 세팅 27 | - NODE_ENV=production 28 | 29 | # MySQL 세팅 30 | - MYSQL_HOST=${MYSQL_HOST} 31 | - MYSQL_PORT=${MYSQL_PORT} 32 | - MYSQL_USER=${MYSQL_USER} 33 | - MYSQL_PASSWORD=${MYSQL_PASSWORD} 34 | - MYSQL_DATABASE=${MYSQL_DATABASE} 35 | 36 | # Mongo 세팅 37 | - MONGO_HOST=${MONGO_HOST} 38 | - MONGO_USER=${MONGO_USER} 39 | - MONGO_PASSWORD=${MONGO_PASSWORD} 40 | - MONGO_DB=${MONGO_DB} 41 | - LOG_LEVEL=${LOG_LEVEL} 42 | networks: 43 | - app-network 44 | 45 | frontend: 46 | container_name: frontend 47 | depends_on: 48 | backend: 49 | condition: service_started 50 | ports: 51 | - "80:80" 52 | environment: 53 | - NODE_ENV=production 54 | - BACKEND_URL=http://backend:3000 55 | extra_hosts: 56 | - "db-host:${DATABASE_HOST}" 57 | networks: 58 | - app-network 59 | 60 | networks: 61 | app-network: 62 | driver: bridge 63 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | backend: 3 | build: 4 | context: . 5 | dockerfile: ./packages/backend/Dockerfile 6 | restart: unless-stopped 7 | environment: 8 | - NODE_ENV=development 9 | networks: 10 | - app-network 11 | 12 | frontend: 13 | build: 14 | context: . 15 | dockerfile: ./packages/frontend/Dockerfile 16 | restart: unless-stopped 17 | environment: 18 | - NODE_ENV=development 19 | networks: 20 | - app-network 21 | 22 | networks: 23 | app-network: 24 | driver: bridge 25 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | import pluginJs from "@eslint/js"; 3 | import prettierConfig from "eslint-config-prettier"; 4 | import prettierPluginRecommended from "eslint-plugin-prettier/recommended"; 5 | import tseslint from "typescript-eslint"; 6 | 7 | /** @see https://www.raulmelo.me/en/blog/migration-eslint-to-flat-config */ 8 | import { FlatCompat } from "@eslint/eslintrc"; 9 | import path from "path"; 10 | import { fileURLToPath } from "url"; 11 | // mimic CommonJS variables -- not needed if using CommonJS 12 | const __filename = fileURLToPath(import.meta.url); 13 | const __dirname = path.dirname(__filename); 14 | const compat = new FlatCompat({ 15 | baseDirectory: __dirname, // optional; default: process.cwd() 16 | resolvePluginsRelativeTo: __dirname, // optional 17 | }); 18 | 19 | /** @type {import('eslint').Linter.Config[]} */ 20 | export default [ 21 | { files: ["**/*.{js,mjs,cjs,ts}"] }, 22 | pluginJs.configs.recommended, 23 | ...tseslint.configs.recommended, 24 | ...compat.extends("airbnb-base"), 25 | prettierConfig, 26 | prettierPluginRecommended, 27 | { 28 | rules: { 29 | "import/no-extraneous-dependencies": [ 30 | "warn", 31 | { 32 | devDependencies: [ 33 | "**/*.config.{mts,ts,mjs,js}", 34 | "**/storybook/**", 35 | "**/stories/**", 36 | "**/*.stories.{ts,tsx,js,jsx}", 37 | "**/*.{spec,test}.{ts,tsx,js,jsx}", 38 | ], 39 | }, 40 | ], 41 | }, 42 | }, 43 | ]; 44 | -------------------------------------------------------------------------------- /example/.env.example: -------------------------------------------------------------------------------- 1 | # Redis 2 | REDIS_PASSWORD=your_redis_password 3 | 4 | # MongoDB 5 | MONGO_USERNAME=your_mongo_username 6 | MONGO_PASSWORD=your_mongo_password 7 | 8 | # MySQL 9 | MYSQL_ROOT_PASSWORD=your_mysql_root_password 10 | MYSQL_DATABASE=your_database_name 11 | MYSQL_USER=your_mysql_username 12 | MYSQL_PASSWORD=your_mysql_password -------------------------------------------------------------------------------- /example/docker-compose.db.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | redis: 5 | image: redis:latest 6 | container_name: redis 7 | command: redis-server --requirepass ${REDIS_PASSWORD} 8 | ports: 9 | - "6379:6379" 10 | volumes: 11 | - redis_data:/data 12 | networks: 13 | - database_network 14 | restart: unless-stopped 15 | environment: 16 | - REDIS_PASSWORD=${REDIS_PASSWORD} 17 | 18 | mongodb: 19 | image: mongo:latest 20 | container_name: mongodb 21 | ports: 22 | - "27017:27017" 23 | volumes: 24 | - mongodb_data:/data/db 25 | networks: 26 | - database_network 27 | restart: unless-stopped 28 | environment: 29 | - MONGO_INITDB_ROOT_USERNAME=${MONGO_USERNAME} 30 | - MONGO_INITDB_ROOT_PASSWORD=${MONGO_PASSWORD} 31 | 32 | mysql: 33 | image: mysql:8 34 | container_name: mysql 35 | ports: 36 | - "3306:3306" 37 | volumes: 38 | - mysql_data:/var/lib/mysql 39 | networks: 40 | - database_network 41 | restart: unless-stopped 42 | environment: 43 | - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} 44 | - MYSQL_DATABASE=${MYSQL_DATABASE} 45 | - MYSQL_USER=${MYSQL_USER} 46 | - MYSQL_PASSWORD=${MYSQL_PASSWORD} 47 | 48 | networks: 49 | database_network: 50 | driver: bridge 51 | 52 | volumes: 53 | redis_data: 54 | mongodb_data: 55 | mysql_data: -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web29-honeyflow", 3 | "version": "1.0.0", 4 | "description": "", 5 | "private": "true", 6 | "scripts": { 7 | "lint": "eslint --filter \"frontend,backend\" lint", 8 | "test": "echo \"Error: no test specified\" && exit 1", 9 | "prepare": "husky" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@commitlint/cli": "^19.5.0", 16 | "@commitlint/config-conventional": "^19.5.0", 17 | "eslint": "^9.14.0", 18 | "eslint-plugin-import": "^2.31.0", 19 | "husky": "^9.1.6", 20 | "lint-staged": "^15.2.10", 21 | "prettier": "3.3.3", 22 | "typescript": "^5.6.3", 23 | "typescript-eslint": "^8.13.0", 24 | "@eslint/eslintrc": "^3.1.0", 25 | "@eslint/js": "^9.14.0", 26 | "eslint-config-airbnb-base": "^15.0.0", 27 | "eslint-config-prettier": "^9.1.0", 28 | "eslint-plugin-prettier": "^5.2.1" 29 | }, 30 | "dependencies": {}, 31 | "lint-staged": { 32 | "src/**/*.{js,jsx,ts,tsx}": [ 33 | "eslint --cache --fix", 34 | "prettier --cache --write" 35 | ] 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/.env.example: -------------------------------------------------------------------------------- 1 | # MySQL 설정 2 | MYSQL_HOST=your-mysql-host 3 | MYSQL_PORT=3306 4 | MYSQL_USER=your-mysql-username 5 | MYSQL_PASSWORD=your-mysql-password 6 | MYSQL_DATABASE=your-mysql-database 7 | 8 | # MongoDB 설정 9 | MONGO_HOST=your-mongo-host 10 | MONGO_USER=your-mongo-username 11 | MONGO_PASSWORD=your-mongo-password 12 | MONGO_DB=your-mongo-database 13 | 14 | # Redis 설정 (추가된 telnet 기반 체크를 위해 필요) 15 | REDIS_HOST=your-redis-host 16 | REDIS_PORT=6379 17 | 18 | # 기타 설정 19 | DATABASE_HOST=your-database-host 20 | LOG_LEVEL=info 21 | 22 | # GitHub Actions에 필요한 설정 23 | SERVER_HOST=your-server-ip 24 | SERVER_USER=your-server-username 25 | SERVER_SSH_PORT=22 26 | SSH_KEY=your-ssh-private-key-content 27 | ENV_FILE_CONTENTS=$(cat .env) # GitHub Secrets로 저장된 ENV 파일 내용 28 | -------------------------------------------------------------------------------- /packages/backend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .git 3 | .gitignore 4 | *.md 5 | dist -------------------------------------------------------------------------------- /packages/backend/.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 | a.env 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 | .env.prod 45 | 46 | # temp directory 47 | .temp 48 | .tmp 49 | 50 | # Runtime data 51 | pids 52 | *.pid 53 | *.seed 54 | *.pid.lock 55 | 56 | # Diagnostic reports (https://nodejs.org/api/report.html) 57 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 58 | -------------------------------------------------------------------------------- /packages/backend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "endOfLine": "auto" 5 | } 6 | -------------------------------------------------------------------------------- /packages/backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine AS builder 2 | 3 | WORKDIR /app 4 | 5 | RUN apk add --no-cache python3 make g++ && npm install -g pnpm 6 | COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ 7 | COPY ./packages/backend ./packages/backend/ 8 | COPY ./packages/shared ./packages/shared/ 9 | 10 | RUN HUSKY=0 pnpm install --no-frozen-lockfile 11 | 12 | COPY ./packages/backend ./packages/backend 13 | COPY ./packages/shared ./packages/shared 14 | COPY ./tsconfig.json ./ 15 | RUN cd ./packages/backend && pnpm build 16 | 17 | FROM node:20-alpine AS production 18 | 19 | WORKDIR /app 20 | 21 | RUN npm install -g pnpm 22 | 23 | COPY --from=builder /app/package.json /app/pnpm-workspace.yaml ./ 24 | COPY --from=builder /app/packages/backend/package.json ./packages/backend/ 25 | COPY --from=builder /app/packages/shared/package.json ./packages/shared/ 26 | 27 | RUN HUSKY=0 pnpm install --no-frozen-lockfile --prod --ignore-scripts 28 | 29 | COPY --from=builder /app/packages/backend/dist ./packages/backend/dist 30 | 31 | COPY --from=builder /app/packages/shared ./packages/shared 32 | 33 | WORKDIR /app/packages/backend 34 | 35 | EXPOSE 3000 36 | 37 | CMD ["node", "dist/main"] -------------------------------------------------------------------------------- /packages/backend/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 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "build": "nest build", 10 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 11 | "start": "nest start", 12 | "start:dev": "nest start --watch", 13 | "start:debug": "nest start --debug --watch", 14 | "start:prod": "node dist/backend/src/main.js", 15 | "test": "jest", 16 | "test:watch": "jest --watch", 17 | "test:cov": "jest --coverage", 18 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 19 | "test:e2e": "jest --config ./test/jest-e2e.json" 20 | }, 21 | "dependencies": { 22 | "@elastic/elasticsearch": "^8.16.1", 23 | "@nestjs/cache-manager": "^2.3.0", 24 | "@nestjs/common": "^10.4.7", 25 | "@nestjs/config": "^3.3.0", 26 | "@nestjs/core": "^10.4.7", 27 | "@nestjs/elasticsearch": "^10.0.2", 28 | "@nestjs/mongoose": "^10.1.0", 29 | "@nestjs/platform-express": "^10.0.0", 30 | "@nestjs/platform-socket.io": "^10.4.8", 31 | "@nestjs/platform-ws": "^10.4.8", 32 | "@nestjs/schedule": "^4.1.1", 33 | "@nestjs/swagger": "^8.0.7", 34 | "@nestjs/terminus": "^10.2.3", 35 | "@nestjs/typeorm": "^10.0.2", 36 | "@nestjs/websockets": "^10.4.8", 37 | "dotenv": "^16.4.5", 38 | "mongoose": "^8.8.1", 39 | "mysql2": "^3.11.4", 40 | "nest-winston": "^1.9.7", 41 | "prosemirror": "^0.11.1", 42 | "reflect-metadata": "^0.2.2", 43 | "rxjs": "^7.8.1", 44 | "shared": "workspace:*", 45 | "socket.io": "^4.8.1", 46 | "swagger-ui-express": "^5.0.1", 47 | "typeorm": "^0.3.20", 48 | "utils": "link:@milkdown/kit/utils", 49 | "uuid": "^11.0.3", 50 | "winston": "^3.17.0", 51 | "winston-daily-rotate-file": "^5.0.0", 52 | "ws": "^8.18.0", 53 | "y-socket.io": "^1.1.3", 54 | "y-websocket": "^2.0.4", 55 | "yjs": "^13.6.20" 56 | }, 57 | "devDependencies": { 58 | "@nestjs/cli": "^10.0.0", 59 | "@nestjs/schematics": "^10.0.0", 60 | "@nestjs/testing": "^10.0.0", 61 | "@types/express": "^5.0.0", 62 | "@types/jest": "^29.5.2", 63 | "@types/mongoose": "^5.11.97", 64 | "@types/node": "^20.17.6", 65 | "@types/socket.io": "^3.0.2", 66 | "@types/supertest": "^6.0.0", 67 | "@types/uuid": "^10.0.0", 68 | "@types/ws": "^8.5.13", 69 | "jest": "^29.5.0", 70 | "prettier": "^3.0.0", 71 | "source-map-support": "^0.5.21", 72 | "supertest": "^7.0.0", 73 | "ts-jest": "^29.1.0", 74 | "ts-loader": "^9.4.3", 75 | "ts-node": "^10.9.2", 76 | "tsconfig-paths": "^4.2.0", 77 | "typescript": "^5.6.3" 78 | }, 79 | "jest": { 80 | "moduleFileExtensions": [ 81 | "js", 82 | "json", 83 | "ts" 84 | ], 85 | "rootDir": "src", 86 | "testRegex": ".*\\.spec\\.ts$", 87 | "transform": { 88 | "^.+\\.(t|j)s$": "ts-jest" 89 | }, 90 | "collectCoverageFrom": [ 91 | "**/*.(t|j)s" 92 | ], 93 | "coverageDirectory": "../coverage", 94 | "testEnvironment": "node" 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /packages/backend/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Logger, Module, OnModuleInit } from '@nestjs/common'; 2 | import { ConfigModule, ConfigService } from '@nestjs/config'; 3 | import { MongooseModule } from '@nestjs/mongoose'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | 6 | import { CollaborativeModule } from './collaborative/collaborative.module'; 7 | import { getMongooseConfig } from './common/config/mongo.config'; 8 | import { getTypeOrmConfig } from './common/config/typeorm.config'; 9 | import { NoteModule } from './note/note.module'; 10 | import { SpaceModule } from './space/space.module'; 11 | import { YjsModule } from './yjs/yjs.module'; 12 | import { TestModule } from './test/test.module'; 13 | 14 | @Module({ 15 | imports: [ 16 | ConfigModule.forRoot({ 17 | isGlobal: true, 18 | }), 19 | MongooseModule.forRootAsync({ 20 | inject: [ConfigService], 21 | useFactory: getMongooseConfig, 22 | }), 23 | TypeOrmModule.forRootAsync({ 24 | inject: [ConfigService], 25 | useFactory: getTypeOrmConfig, 26 | }), 27 | SpaceModule, 28 | YjsModule, 29 | NoteModule, 30 | TestModule, 31 | CollaborativeModule, 32 | ], 33 | }) 34 | export class AppModule implements OnModuleInit { 35 | private readonly logger = new Logger(AppModule.name); 36 | 37 | async onModuleInit(): Promise { 38 | this.logger.debug('Application initialized for debug'); 39 | this.logger.log('Application initialized', { 40 | module: 'AppModule', 41 | environment: process.env.NODE_ENV ?? 'development', 42 | timestamp: new Date().toISOString(), 43 | }); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/backend/src/collaborative/collaborative.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { NoteModule } from '../note/note.module'; 4 | import { SpaceModule } from '../space/space.module'; 5 | import { CollaborativeService } from './collaborative.service'; 6 | 7 | @Module({ 8 | imports: [NoteModule, SpaceModule], 9 | providers: [CollaborativeService], 10 | exports: [CollaborativeService], 11 | }) 12 | export class CollaborativeModule {} 13 | -------------------------------------------------------------------------------- /packages/backend/src/collaborative/collaborative.service.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable, Logger } from '@nestjs/common'; 2 | 3 | import { ERROR_MESSAGES } from '../common/constants/error.message.constants'; 4 | import { NoteService } from '../note/note.service'; 5 | import { SpaceService } from '../space/space.service'; 6 | 7 | @Injectable() 8 | export class CollaborativeService { 9 | private readonly logger = new Logger(CollaborativeService.name); 10 | 11 | constructor( 12 | private readonly spaceService: SpaceService, 13 | private readonly noteService: NoteService, 14 | ) {} 15 | 16 | async updateBySpace(id: string, space: string) { 17 | try { 18 | this.logger.log('스페이스 정보 업데이트 시작', { 19 | method: 'updateBySpace', 20 | spaceId: id, 21 | length: space.length, 22 | }); 23 | 24 | let spaceJsonData; 25 | try { 26 | spaceJsonData = JSON.parse(space); 27 | } catch (error) { 28 | throw new Error(`유효하지 않은 스페이스 JSON 데이터: ${error.message}`); 29 | } 30 | 31 | const updateDto = { 32 | edges: JSON.stringify(spaceJsonData.edges), 33 | nodes: JSON.stringify(spaceJsonData.nodes), 34 | }; 35 | 36 | const result = await this.spaceService.updateById(id, updateDto); 37 | 38 | this.logger.log('스페이스 정보 업데이트 완료', { 39 | method: 'updateBySpace', 40 | spaceId: id, 41 | success: !!result, 42 | }); 43 | 44 | return result; 45 | } catch (error) { 46 | this.logger.error('스페이스 정보 업데이트 실패', { 47 | method: 'updateBySpace', 48 | spaceId: id, 49 | error: error.message, 50 | stack: error.stack, 51 | }); 52 | throw error; 53 | } 54 | } 55 | 56 | async findBySpace(id: string) { 57 | try { 58 | this.logger.log('스페이스 정보 조회 시작', { 59 | method: 'findBySpace', 60 | spaceId: id, 61 | }); 62 | 63 | const space = await this.spaceService.findById(id); 64 | 65 | this.logger.log('스페이스 정보 조회 완료', { 66 | method: 'findBySpace', 67 | spaceId: id, 68 | found: !!space, 69 | }); 70 | 71 | return space; 72 | } catch (error) { 73 | this.logger.error('스페이스 정보 조회 실패', { 74 | method: 'findBySpace', 75 | spaceId: id, 76 | error: error.message, 77 | stack: error.stack, 78 | }); 79 | throw error; 80 | } 81 | } 82 | 83 | async updateByNote(id: string, note: string) { 84 | try { 85 | this.logger.log('노트 내용 업데이트 시작', { 86 | method: 'updateByNote', 87 | noteId: id, 88 | length: note.length, 89 | }); 90 | 91 | const updatedNote = await this.noteService.updateContent(id, note); 92 | 93 | this.logger.log('노트 내용 업데이트 완료', { 94 | method: 'updateByNote', 95 | noteId: id, 96 | }); 97 | 98 | return updatedNote; 99 | } catch (error) { 100 | this.logger.error('노트 내용 업데이트 실패', { 101 | method: 'updateByNote', 102 | noteId: id, 103 | error: error.message, 104 | stack: error.stack, 105 | }); 106 | throw new BadRequestException(ERROR_MESSAGES.NOTE.UPDATE_FAILED); 107 | } 108 | } 109 | 110 | async findByNote(id: string) { 111 | try { 112 | this.logger.log('노트 조회 시작', { 113 | method: 'findByNote', 114 | noteId: id, 115 | }); 116 | 117 | const note = await this.noteService.findById(id); 118 | 119 | this.logger.log('노트 조회 완료', { 120 | method: 'findByNote', 121 | noteId: id, 122 | found: !!note, 123 | }); 124 | 125 | return note; 126 | } catch (error) { 127 | this.logger.error('노트 조회 실패', { 128 | method: 'findByNote', 129 | noteId: id, 130 | error: error.message, 131 | stack: error.stack, 132 | }); 133 | throw error; 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /packages/backend/src/collaborative/collaborative.type.ts: -------------------------------------------------------------------------------- 1 | export interface SpaceDocument { 2 | id: string; 3 | parentContextNodeId: string | null; 4 | edges: string; 5 | nodes: string; 6 | } 7 | 8 | export interface NoteDocument { 9 | id: string; 10 | content: string; 11 | } 12 | 13 | export type DocumentType = 'space' | 'note'; 14 | export type Document = SpaceDocument | NoteDocument; 15 | -------------------------------------------------------------------------------- /packages/backend/src/common/config/mongo.config.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from '@nestjs/config'; 2 | import { MongooseModuleOptions } from '@nestjs/mongoose'; 3 | 4 | export const getMongooseConfig = ( 5 | configService: ConfigService, 6 | ): MongooseModuleOptions => { 7 | const host = configService.get('MONGO_HOST'); 8 | const user = configService.get('MONGO_USER'); 9 | const pass = configService.get('MONGO_PASSWORD'); 10 | const dbName = configService.get('MONGO_DB'); 11 | 12 | const uri = `mongodb://${user}:${pass}@${host}:27017/${dbName}`; 13 | 14 | return { 15 | uri, 16 | authSource: 'admin', 17 | authMechanism: 'SCRAM-SHA-256', 18 | connectionFactory: (connection) => { 19 | return connection; 20 | }, 21 | }; 22 | }; 23 | -------------------------------------------------------------------------------- /packages/backend/src/common/config/typeorm.config.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from '@nestjs/config'; 2 | import { TypeOrmModuleOptions } from '@nestjs/typeorm'; 3 | import { join } from 'path'; 4 | 5 | export const getTypeOrmConfig = async ( 6 | configService: ConfigService, 7 | ): Promise => { 8 | const host = configService.get('MYSQL_HOST'); 9 | const port = configService.get('MYSQL_PORT'); 10 | const database = configService.get('MYSQL_DATABASE'); 11 | const username = configService.get('MYSQL_USER'); 12 | const password = configService.get('MYSQL_PASSWORD'); 13 | 14 | return { 15 | type: 'mysql', 16 | host, 17 | port, 18 | username, 19 | password, 20 | database, 21 | entities: [join(__dirname, '..', '**', '*.entity.{ts,js}')], 22 | synchronize: process.env.NODE_ENV !== 'production', 23 | autoLoadEntities: true, 24 | logging: true, 25 | }; 26 | }; 27 | -------------------------------------------------------------------------------- /packages/backend/src/common/constants/error.message.constants.ts: -------------------------------------------------------------------------------- 1 | export const ERROR_MESSAGES = { 2 | SPACE: { 3 | BAD_REQUEST: '잘못된 요청입니다.', 4 | LIMIT_EXCEEDED: '스페이스 최대 생성한도 초과', 5 | NOT_FOUND: '존재하지 않는 스페이스입니다.', 6 | CREATION_FAILED: '스페이스 생성에 실패하였습니다.', 7 | UPDATE_FAILED: '스페이스 업데이트에 실패하였습니다.', 8 | INITIALIZE_FAILED: '스페이스가 초기화에 실패하였습니다.', 9 | PARENT_NOT_FOUND: '부모 스페이스가 존재하지 않습니다.', 10 | DELETE_FAILED: '노트 삭제에 실패하였습니다.', 11 | }, 12 | NOTE: { 13 | BAD_REQUEST: '잘못된 요청입니다.', 14 | NOT_FOUND: '노트가 존재하지 않습니다.', 15 | CREATION_FAILED: '노트 생성에 실패하였습니다.', 16 | UPDATE_FAILED: '노트 업데이트에 실패하였습니다.', 17 | INITIALIZE_FAILED: '노트가 초기화에 실패하였습니다.', 18 | DELETE_FAILED: '노트 삭제에 실패하였습니다.', 19 | }, 20 | SOCKET: { 21 | INVALID_URL: '유효하지 않은 URL 주소입니다.', 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /packages/backend/src/common/constants/space.constants.ts: -------------------------------------------------------------------------------- 1 | export const MAX_SPACES = 9999; 2 | export const GUEST_USER_ID = 'honeyflow'; 3 | -------------------------------------------------------------------------------- /packages/backend/src/common/constants/websocket.constants.ts: -------------------------------------------------------------------------------- 1 | export const WebsocketStatus = { 2 | NORMAL_CLOSURE: 1000, 3 | GOING_AWAY: 1001, 4 | PROTOCOL_ERROR: 1002, 5 | UNSUPPORTED_DATA: 1003, 6 | RESERVED: 1004, 7 | NO_STATUS_RECEIVED: 1005, 8 | ABNORMAL_CLOSURE: 1006, 9 | INVALID_FRAME_PAYLOAD: 1007, 10 | POLICY_VIOLATION: 1008, 11 | MESSAGE_TOO_BIG: 1009, 12 | MANDATORY_EXTENSION: 1010, 13 | INTERNAL_SERVER_ERROR: 1011, 14 | SERVICE_RESTART: 1012, 15 | TRY_AGAIN_LATER: 1013, 16 | BAD_GATEWAY: 1014, 17 | TLS_HANDSHAKE_FAILURE: 1015, 18 | }; 19 | -------------------------------------------------------------------------------- /packages/backend/src/common/filters/all-exceptions.filter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExceptionFilter, 3 | Catch, 4 | HttpException, 5 | ArgumentsHost, 6 | } from '@nestjs/common'; 7 | import { Response } from 'express'; 8 | 9 | @Catch(HttpException) 10 | export class AllExceptionsFilter implements ExceptionFilter { 11 | catch(exception: HttpException, host: ArgumentsHost) { 12 | const ctx = host.switchToHttp(); 13 | const response = ctx.getResponse(); 14 | const status = exception.getStatus(); 15 | const message = exception.message || 'Internal server error'; 16 | response.status(status).json({ 17 | statusCode: status, 18 | message, 19 | }); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/backend/src/common/utils/socket.util.ts: -------------------------------------------------------------------------------- 1 | export function parseSocketUrl(url: string): { 2 | urlType: string | null; 3 | urlId: string | null; 4 | } { 5 | try { 6 | const isAbsoluteUrl = url.startsWith('ws://') || url.startsWith('wss://'); 7 | 8 | const baseUrl = 'ws://localhost'; 9 | const fullUrl = isAbsoluteUrl ? url : `${baseUrl}${url}`; 10 | const { pathname } = new URL(fullUrl); 11 | 12 | const parts = pathname.split('/').filter((part) => part.length > 0); 13 | 14 | if (parts.length >= 2) { 15 | return { urlType: parts[1], urlId: parts[2] }; 16 | } 17 | return { urlType: null, urlId: null }; 18 | 19 | } catch (error) { 20 | return { urlType: null, urlId: null }; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/backend/src/env.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'y-websocket/bin/utils' { 2 | export function setPersistence( 3 | persistence_: { 4 | bindState: (arg0: string, arg1: WSSharedDoc) => void; 5 | writeState: (arg0: string, arg1: WSSharedDoc) => Promise; 6 | provider: any; 7 | } | null, 8 | ): void; 9 | export function getPersistence(): null | { 10 | bindState: (arg0: string, arg1: WSSharedDoc) => void; 11 | writeState: (arg0: string, arg1: WSSharedDoc) => Promise; 12 | } | null; 13 | export function setContentInitializor( 14 | f: (ydoc: Y.Doc) => Promise, 15 | ): void; 16 | export function setupWSConnection( 17 | conn: import('ws').WebSocket, 18 | req: import('http').IncomingMessage, 19 | { docName, gc }?: any, 20 | ): void; 21 | export class WSSharedDoc extends Y.Doc { 22 | /** 23 | * @param {string} name 24 | */ 25 | constructor(name: string); 26 | name: string; 27 | /** 28 | * Maps from conn to set of controlled user ids. Delete all user ids from awareness when this conn is closed 29 | * @type {Map>} 30 | */ 31 | conns: Map>; 32 | /** 33 | * @type {awarenessProtocol.Awareness} 34 | */ 35 | awareness: awarenessProtocol.Awareness; 36 | whenInitialized: Promise; 37 | } 38 | /** 39 | * @type {Map} 40 | */ 41 | export const docs: Map; 42 | import Y = require('yjs'); 43 | /** 44 | * Gets a Y.Doc by name, whether in memory or on disk 45 | * 46 | * @param {string} docname - the name of the Y.Doc to find or create 47 | * @param {boolean} gc - whether to allow gc on the doc (applies only when created) 48 | * @return {WSSharedDoc} 49 | */ 50 | export function getYDoc(docname: string, gc?: boolean): WSSharedDoc; 51 | import awarenessProtocol = require('y-protocols/awareness'); 52 | } 53 | -------------------------------------------------------------------------------- /packages/backend/src/main.ts: -------------------------------------------------------------------------------- 1 | import { Logger, VersioningType } from '@nestjs/common'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { WsAdapter } from '@nestjs/platform-ws'; 4 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; 5 | import type { INestApplication } from '@nestjs/common'; 6 | 7 | import { AppModule } from './app.module'; 8 | import { AllExceptionsFilter } from './common/filters/all-exceptions.filter'; 9 | 10 | const ALLOWED_ORIGINS = [ 11 | 'http://www.honeyflow.life', 12 | 'https://www.honeyflow.life', 13 | 'http://localhost', 14 | ] as string[]; 15 | 16 | function configureGlobalSettings(app: INestApplication) { 17 | app.setGlobalPrefix('/api'); 18 | app.useGlobalFilters(new AllExceptionsFilter()); 19 | app.useWebSocketAdapter(new WsAdapter(app)); 20 | app.enableCors({ 21 | origin: (origin, callback) => { 22 | if (!origin || ALLOWED_ORIGINS.includes(origin)) { 23 | callback(null, origin); 24 | } else { 25 | callback(new Error('Not allowed by CORS')); 26 | } 27 | }, 28 | methods: 'GET, POST, PUT, DELETE', 29 | allowedHeaders: 'Content-Type, Authorization', 30 | credentials: true, 31 | }); 32 | app.enableVersioning({ 33 | type: VersioningType.URI, 34 | defaultVersion: '1', 35 | }); 36 | } 37 | 38 | function configureSwagger(app: INestApplication) { 39 | const config = new DocumentBuilder() 40 | .setTitle('API 문서') 41 | .setDescription('API 설명') 42 | .setVersion('2.0') 43 | .build(); 44 | 45 | const document = SwaggerModule.createDocument(app, config); 46 | SwaggerModule.setup('api-docs', app, document); 47 | } 48 | 49 | async function bootstrap() { 50 | const app = await NestFactory.create(AppModule); 51 | const logger = new Logger('Bootstrap'); 52 | 53 | configureGlobalSettings(app); 54 | configureSwagger(app); 55 | 56 | const PORT = process.env.PORT ?? 3000; 57 | await app.listen(PORT); 58 | 59 | logger.log(`Honeyflow started on port ${PORT}`); 60 | } 61 | 62 | bootstrap(); 63 | -------------------------------------------------------------------------------- /packages/backend/src/note/dto/note.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class CreateNoteDto { 4 | @ApiProperty({ description: '유저 ID' }) 5 | userId: string; 6 | @ApiProperty({ description: '노트 제목' }) 7 | noteName: string; 8 | } 9 | -------------------------------------------------------------------------------- /packages/backend/src/note/note.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | HttpException, 7 | HttpStatus, 8 | Logger, 9 | Param, 10 | Post, 11 | Version, 12 | } from '@nestjs/common'; 13 | import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; 14 | 15 | import { ERROR_MESSAGES } from '../common/constants/error.message.constants'; 16 | import { GUEST_USER_ID } from '../common/constants/space.constants'; 17 | import { CreateNoteDto } from './dto/note.dto'; 18 | import { NoteService } from './note.service'; 19 | 20 | @ApiTags('note') 21 | @Controller('note') 22 | export class NoteController { 23 | private readonly logger = new Logger(NoteController.name); 24 | 25 | constructor(private readonly noteService: NoteService) {} 26 | 27 | @Version('1') 28 | @Post() 29 | @ApiOperation({ summary: '노트 생성' }) 30 | @ApiResponse({ status: 201, description: '노트 생성 성공' }) 31 | @ApiResponse({ status: 400, description: '잘못된 요청' }) 32 | async createNote(@Body() createNoteDto: CreateNoteDto) { 33 | const { userId, noteName } = createNoteDto; 34 | 35 | this.logger.log('노트 생성 요청 수신', { 36 | method: 'createNote', 37 | userId, 38 | noteName, 39 | }); 40 | 41 | if (userId !== GUEST_USER_ID || !noteName) { 42 | this.logger.error('노트 생성 요청 실패 - 잘못된 요청', { 43 | method: 'createNote', 44 | userId, 45 | noteName, 46 | error: ERROR_MESSAGES.NOTE.BAD_REQUEST, 47 | }); 48 | 49 | throw new HttpException( 50 | ERROR_MESSAGES.NOTE.BAD_REQUEST, 51 | HttpStatus.BAD_REQUEST, 52 | ); 53 | } 54 | 55 | try { 56 | const note = await this.noteService.create(userId, noteName); 57 | 58 | this.logger.log('노트 생성 성공', { 59 | method: 'createNote', 60 | userId, 61 | noteName, 62 | noteId: note.toObject().id, 63 | }); 64 | 65 | return { 66 | urlPath: note.toObject().id, 67 | }; 68 | } catch (error) { 69 | this.logger.error('노트 생성 중 예상치 못한 오류 발생', { 70 | method: 'createNote', 71 | error: error.message, 72 | stack: error.stack, 73 | }); 74 | 75 | throw new HttpException( 76 | ERROR_MESSAGES.NOTE.CREATION_FAILED, 77 | HttpStatus.INTERNAL_SERVER_ERROR, 78 | ); 79 | } 80 | } 81 | 82 | @Version('1') 83 | @Get('/:id') 84 | @ApiOperation({ summary: '노트 조회' }) 85 | @ApiResponse({ status: 200, description: '노트 조회 성공' }) 86 | @ApiResponse({ status: 404, description: '노트 조회 실패' }) 87 | async existsByNote(@Param('id') id: string) { 88 | this.logger.log('노트 조회 요청 수신', { 89 | method: 'existsByNote', 90 | id, 91 | }); 92 | 93 | try { 94 | const result = await this.noteService.existsById(id); 95 | 96 | this.logger.log('노트 조회 완료', { 97 | method: 'existsByNote', 98 | id, 99 | found: result, 100 | }); 101 | 102 | return result; 103 | } catch (error) { 104 | this.logger.error('노트 조회 중 예상치 못한 오류 발생', { 105 | method: 'existsByNote', 106 | id, 107 | error: error.message, 108 | stack: error.stack, 109 | }); 110 | 111 | throw new HttpException( 112 | ERROR_MESSAGES.NOTE.NOT_FOUND, 113 | HttpStatus.INTERNAL_SERVER_ERROR, 114 | ); 115 | } 116 | } 117 | 118 | @Version('1') 119 | @Delete('/:id') 120 | @ApiOperation({ summary: '노트 조회' }) 121 | @ApiResponse({ status: 200, description: '노트 조회 성공' }) 122 | @ApiResponse({ status: 404, description: '노트 조회 실패' }) 123 | async deleteNote(@Param('id') id: string) { 124 | const result = await this.noteService.deleteById(id); 125 | this.logger.log('노트 삭제 완료', { 126 | method: 'deleteNote', 127 | id, 128 | result: !!result, 129 | }); 130 | return !!result; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /packages/backend/src/note/note.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MongooseModule } from '@nestjs/mongoose'; 3 | 4 | import { NoteController } from './note.controller'; 5 | import { NoteDocument, NoteSchema } from './note.schema'; 6 | import { NoteService } from './note.service'; 7 | 8 | @Module({ 9 | imports: [ 10 | MongooseModule.forFeature([ 11 | { name: NoteDocument.name, schema: NoteSchema }, 12 | ]), 13 | ], 14 | controllers: [NoteController], 15 | providers: [NoteService], 16 | exports: [NoteService], 17 | }) 18 | export class NoteModule {} 19 | -------------------------------------------------------------------------------- /packages/backend/src/note/note.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import { Document } from 'mongoose'; 3 | 4 | @Schema({ 5 | timestamps: true, 6 | versionKey: false, 7 | }) 8 | export class NoteDocument extends Document { 9 | @Prop({ required: true, unique: true }) 10 | id: string; 11 | 12 | @Prop({ required: true }) 13 | userId: string; 14 | 15 | @Prop({ required: true }) 16 | name: string; 17 | 18 | @Prop({ type: String, default: null }) 19 | content: string | null; 20 | } 21 | 22 | export const NoteSchema = SchemaFactory.createForClass(NoteDocument); 23 | -------------------------------------------------------------------------------- /packages/backend/src/note/note.service.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable, Logger } from '@nestjs/common'; 2 | import { InjectModel } from '@nestjs/mongoose'; 3 | import { Model } from 'mongoose'; 4 | import { v4 as uuid } from 'uuid'; 5 | 6 | import { ERROR_MESSAGES } from '../common/constants/error.message.constants'; 7 | import { NoteDocument } from './note.schema'; 8 | 9 | @Injectable() 10 | export class NoteService { 11 | private readonly logger = new Logger(NoteService.name); 12 | 13 | constructor( 14 | @InjectModel(NoteDocument.name) 15 | private readonly noteModel: Model, 16 | ) {} 17 | 18 | async create(userId: string, noteName: string) { 19 | this.logger.log(`사용자 ${userId}에 대한 새로운 노트를 생성 중입니다.`); 20 | 21 | const noteDto = { 22 | id: uuid(), 23 | userId, 24 | name: noteName, 25 | }; 26 | 27 | const note = await this.noteModel.create(noteDto); 28 | 29 | this.logger.debug(`노트 생성 완료 - ID: ${note.id}, 이름: ${note.name}`); 30 | 31 | return note; 32 | } 33 | 34 | async findById(id: string) { 35 | this.logger.log(`ID가 ${id}인 노트를 검색 중입니다.`); 36 | 37 | const note = await this.noteModel.findOne({ id }).exec(); 38 | 39 | this.logger.debug(`ID가 ${id}인 노트 검색 결과: ${!!note}`); 40 | 41 | return note; 42 | } 43 | 44 | async existsById(id: string) { 45 | this.logger.log(`ID가 ${id}인 노트의 존재 여부를 확인 중입니다.`); 46 | 47 | const note = await this.noteModel.findOne({ id }).exec(); 48 | const exists = !!note; 49 | 50 | this.logger.debug(`ID가 ${id}인 노트 존재 여부: ${exists}`); 51 | 52 | return exists; 53 | } 54 | 55 | async updateContent(id: string, newContent: string) { 56 | this.logger.log(`ID가 ${id}인 노트의 내용을 업데이트 중입니다.`); 57 | 58 | const note = await this.findById(id); 59 | 60 | if (!note) { 61 | this.logger.error(`업데이트 실패: ID가 ${id}인 노트를 찾을 수 없습니다.`); 62 | throw new BadRequestException(ERROR_MESSAGES.NOTE.NOT_FOUND); 63 | } 64 | 65 | this.logger.debug(`이전 내용: ${note.content}`); 66 | note.content = newContent; 67 | 68 | try { 69 | const updatedNote = await note.save(); 70 | 71 | this.logger.log(`ID가 ${id}인 노트 내용 업데이트 완료.`); 72 | this.logger.debug(`업데이트된 내용: ${updatedNote.content}`); 73 | 74 | return updatedNote; 75 | } catch (error) { 76 | this.logger.error( 77 | `ID가 ${id}인 노트의 내용 업데이트 중 오류 발생.`, 78 | error.stack, 79 | ); 80 | throw new BadRequestException(ERROR_MESSAGES.NOTE.UPDATE_FAILED); 81 | } 82 | } 83 | async deleteById(id: string) { 84 | this.logger.log(`ID가 ${id}인 노트를 삭제하는 중입니다.`); 85 | 86 | try { 87 | const result = await this.noteModel.deleteOne({ id }).exec(); 88 | 89 | if (result.deletedCount === 0) { 90 | this.logger.warn(`삭제 실패: ID가 ${id}인 노트를 찾을 수 없습니다.`); 91 | throw new BadRequestException(ERROR_MESSAGES.NOTE.NOT_FOUND); 92 | } 93 | 94 | this.logger.log(`ID가 ${id}인 노트 삭제 완료.`); 95 | return { success: true, message: '노트가 성공적으로 삭제되었습니다.' }; 96 | } catch (error) { 97 | this.logger.error(`ID가 ${id}인 노트 삭제 중 오류 발생.`, error.stack); 98 | throw new BadRequestException(ERROR_MESSAGES.NOTE.DELETE_FAILED); 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /packages/backend/src/space/dto/create.space.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class CreateSpaceDto { 4 | @ApiProperty({ description: '유저 ID' }) 5 | userId: string; 6 | @ApiProperty({ description: '스페이스 이름' }) 7 | spaceName: string; 8 | @ApiProperty({ description: 'Parent Space Id' }) 9 | parentContextNodeId: string | null; 10 | } 11 | -------------------------------------------------------------------------------- /packages/backend/src/space/dto/update.space.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class UpdateSpaceDto { 4 | @ApiProperty({ description: '유저 ID' }) 5 | userId: string; 6 | @ApiProperty({ description: '스페이스 이름' }) 7 | spaceName: string; 8 | 9 | @ApiProperty({ description: 'Parent Space Id' }) 10 | parentContextNodeId: string | null; 11 | } 12 | -------------------------------------------------------------------------------- /packages/backend/src/space/space.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { SpaceController } from './space.controller'; 3 | import { SpaceService } from './space.service'; 4 | import { CreateSpaceDto } from './dto/create.space.dto'; 5 | import { HttpException } from '@nestjs/common'; 6 | import { GUEST_USER_ID } from '../common/constants/space.constants'; 7 | 8 | describe('SpaceController', () => { 9 | let spaceController: SpaceController; 10 | let spaceService: Partial; 11 | 12 | beforeEach(async () => { 13 | spaceService = { 14 | existsById: jest.fn(), 15 | getBreadcrumb: jest.fn(), 16 | create: jest.fn(), 17 | }; 18 | 19 | const module: TestingModule = await Test.createTestingModule({ 20 | controllers: [SpaceController], 21 | providers: [ 22 | { 23 | provide: SpaceService, 24 | useValue: spaceService, 25 | }, 26 | ], 27 | }).compile(); 28 | 29 | spaceController = module.get(SpaceController); 30 | }); 31 | 32 | describe('existsBySpace', () => { 33 | it('스페이스가 존재할 경우 true를 반환해야 한다', async () => { 34 | const spaceId = '123'; 35 | (spaceService.existsById as jest.Mock).mockResolvedValue(true); 36 | 37 | const result = await spaceController.existsBySpace(spaceId); 38 | 39 | expect(spaceService.existsById).toHaveBeenCalledWith(spaceId); 40 | expect(result).toBe(true); 41 | }); 42 | 43 | it('예외가 발생하면 오류를 던져야 한다', async () => { 44 | const spaceId = '123'; 45 | (spaceService.existsById as jest.Mock).mockRejectedValue( 46 | new Error('Unexpected Error'), 47 | ); 48 | 49 | await expect(spaceController.existsBySpace(spaceId)).rejects.toThrow( 50 | 'Unexpected Error', 51 | ); 52 | }); 53 | }); 54 | 55 | describe('getBreadcrumb', () => { 56 | it('주어진 스페이스 ID에 대한 경로를 반환해야 한다', async () => { 57 | const spaceId = '123'; 58 | const breadcrumb = ['Home', 'Space']; 59 | (spaceService.getBreadcrumb as jest.Mock).mockResolvedValue(breadcrumb); 60 | 61 | const result = await spaceController.getBreadcrumb(spaceId); 62 | 63 | expect(spaceService.getBreadcrumb).toHaveBeenCalledWith(spaceId); 64 | expect(result).toEqual(breadcrumb); 65 | }); 66 | }); 67 | 68 | describe('createSpace', () => { 69 | it('스페이스를 생성하고 URL 경로를 반환해야 한다', async () => { 70 | const createSpaceDto: CreateSpaceDto = { 71 | userId: GUEST_USER_ID, 72 | spaceName: 'New Space', 73 | parentContextNodeId: '123', 74 | }; 75 | 76 | const mockSpace = { toObject: () => ({ id: 'space123' }) }; 77 | (spaceService.create as jest.Mock).mockResolvedValue(mockSpace); 78 | 79 | const result = await spaceController.createSpace(createSpaceDto); 80 | 81 | expect(spaceService.create).toHaveBeenCalledWith( 82 | GUEST_USER_ID, 83 | 'New Space', 84 | '123', 85 | ); 86 | expect(result).toEqual({ urlPath: 'space123' }); 87 | }); 88 | 89 | it('잘못된 요청인 경우 400 오류를 던져야 한다', async () => { 90 | const createSpaceDto: CreateSpaceDto = { 91 | userId: 'invalidUser', 92 | spaceName: '', 93 | parentContextNodeId: '123', 94 | }; 95 | 96 | await expect(spaceController.createSpace(createSpaceDto)).rejects.toThrow( 97 | HttpException, 98 | ); 99 | 100 | expect(spaceService.create).not.toHaveBeenCalled(); 101 | }); 102 | 103 | it('스페이스 생성에 실패한 경우 404 오류를 던져야 한다', async () => { 104 | const createSpaceDto: CreateSpaceDto = { 105 | userId: GUEST_USER_ID, 106 | spaceName: 'New Space', 107 | parentContextNodeId: '123', 108 | }; 109 | 110 | (spaceService.create as jest.Mock).mockResolvedValue(null); 111 | 112 | await expect(spaceController.createSpace(createSpaceDto)).rejects.toThrow( 113 | HttpException, 114 | ); 115 | 116 | expect(spaceService.create).toHaveBeenCalledWith( 117 | GUEST_USER_ID, 118 | 'New Space', 119 | '123', 120 | ); 121 | }); 122 | }); 123 | }); 124 | -------------------------------------------------------------------------------- /packages/backend/src/space/space.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MongooseModule } from '@nestjs/mongoose'; 3 | 4 | import { SpaceController } from './space.controller'; 5 | import { SpaceDocument, SpaceSchema } from './space.schema'; 6 | import { SpaceService } from './space.service'; 7 | import { SpaceValidation } from './space.validation.service'; 8 | import { NoteModule } from 'src/note/note.module'; 9 | 10 | @Module({ 11 | imports: [ 12 | NoteModule, 13 | MongooseModule.forFeature([ 14 | { name: SpaceDocument.name, schema: SpaceSchema }, 15 | ]), 16 | ], 17 | controllers: [SpaceController], 18 | providers: [SpaceService, SpaceValidation], 19 | exports: [SpaceService], 20 | }) 21 | export class SpaceModule {} 22 | -------------------------------------------------------------------------------- /packages/backend/src/space/space.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import { Document } from 'mongoose'; 3 | 4 | @Schema({ 5 | timestamps: true, 6 | versionKey: false, 7 | }) 8 | export class SpaceDocument extends Document { 9 | @Prop({ required: true, unique: true }) 10 | id: string; 11 | 12 | @Prop({ type: String, default: null, index: true }) 13 | parentSpaceId: string | null; 14 | 15 | @Prop({ required: true }) 16 | userId: string; 17 | 18 | @Prop({ required: true }) 19 | name: string; 20 | 21 | @Prop({ required: true, type: String }) 22 | edges: string; 23 | 24 | @Prop({ required: true, type: String }) 25 | nodes: string; 26 | } 27 | 28 | export const SpaceSchema = SchemaFactory.createForClass(SpaceDocument); 29 | -------------------------------------------------------------------------------- /packages/backend/src/space/space.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { SpaceService } from './space.service'; 3 | import { getModelToken } from '@nestjs/mongoose'; 4 | import { SpaceDocument } from './space.schema'; 5 | import { SpaceValidation } from './space.validation.service'; 6 | import { Model } from 'mongoose'; 7 | 8 | jest.mock('uuid', () => ({ 9 | v4: jest.fn(() => 'mock-uuid'), 10 | })); 11 | 12 | describe('SpaceService', () => { 13 | let spaceService: SpaceService; 14 | let spaceModel: Model; 15 | let spaceValidation: SpaceValidation; 16 | 17 | beforeEach(async () => { 18 | const mockSpaceModel = { 19 | findOne: jest.fn().mockReturnValue({ 20 | exec: jest.fn(), 21 | }), 22 | findOneAndUpdate: jest.fn().mockReturnValue({ 23 | exec: jest.fn(), 24 | }), 25 | countDocuments: jest.fn(), 26 | create: jest.fn(), 27 | }; 28 | 29 | const mockSpaceValidation = { 30 | validateSpaceLimit: jest.fn().mockResolvedValue(undefined), 31 | validateParentNodeExists: jest.fn().mockResolvedValue(undefined), 32 | }; 33 | 34 | const module: TestingModule = await Test.createTestingModule({ 35 | providers: [ 36 | SpaceService, 37 | { 38 | provide: getModelToken(SpaceDocument.name), 39 | useValue: mockSpaceModel, 40 | }, 41 | { 42 | provide: SpaceValidation, 43 | useValue: mockSpaceValidation, 44 | }, 45 | ], 46 | }).compile(); 47 | 48 | spaceService = module.get(SpaceService); 49 | spaceModel = module.get>( 50 | getModelToken(SpaceDocument.name), 51 | ); 52 | spaceValidation = module.get(SpaceValidation); 53 | }); 54 | 55 | describe('getBreadcrumb', () => { 56 | it('스페이스의 경로를 반환해야 한다', async () => { 57 | const mockSpaces = [ 58 | { id: 'parent-id', name: 'Parent Space', parentSpaceId: null }, 59 | { id: '123', name: 'Child Space', parentSpaceId: 'parent-id' }, 60 | ]; 61 | 62 | (spaceModel.findOne as jest.Mock) 63 | .mockReturnValueOnce({ 64 | exec: jest.fn().mockResolvedValue(mockSpaces[1]), 65 | }) 66 | .mockReturnValueOnce({ 67 | exec: jest.fn().mockResolvedValue(mockSpaces[0]), 68 | }); 69 | 70 | const result = await spaceService.getBreadcrumb('123'); 71 | 72 | expect(spaceModel.findOne).toHaveBeenCalledWith({ id: '123' }); 73 | expect(spaceModel.findOne).toHaveBeenCalledWith({ id: 'parent-id' }); 74 | expect(result).toEqual([ 75 | { name: 'Parent Space', url: 'parent-id' }, 76 | { name: 'Child Space', url: '123' }, 77 | ]); 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /packages/backend/src/space/space.validation.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { SpaceValidation } from './space.validation.service'; 3 | import { getModelToken } from '@nestjs/mongoose'; 4 | import { SpaceDocument } from './space.schema'; 5 | import { Model } from 'mongoose'; 6 | import { ERROR_MESSAGES } from '../common/constants/error.message.constants'; 7 | import { MAX_SPACES } from '../common/constants/space.constants'; 8 | 9 | describe('SpaceValidation', () => { 10 | let spaceValidation: SpaceValidation; 11 | let spaceModel: Model; 12 | 13 | beforeEach(async () => { 14 | const mockSpaceModel = { 15 | countDocuments: jest.fn(), 16 | findOne: jest.fn(), 17 | }; 18 | 19 | const module: TestingModule = await Test.createTestingModule({ 20 | providers: [ 21 | SpaceValidation, 22 | { 23 | provide: getModelToken(SpaceDocument.name), 24 | useValue: mockSpaceModel, 25 | }, 26 | ], 27 | }).compile(); 28 | 29 | spaceValidation = module.get(SpaceValidation); 30 | spaceModel = module.get>( 31 | getModelToken(SpaceDocument.name), 32 | ); 33 | }); 34 | 35 | describe('validateSpaceLimit', () => { 36 | it('스페이스가 최대 제한을 초과하면 예외를 던져야 한다', async () => { 37 | (spaceModel.countDocuments as jest.Mock).mockResolvedValue(MAX_SPACES); 38 | 39 | await expect( 40 | spaceValidation.validateSpaceLimit('user123'), 41 | ).rejects.toThrow(ERROR_MESSAGES.SPACE.LIMIT_EXCEEDED); 42 | 43 | expect(spaceModel.countDocuments).toHaveBeenCalledWith({ 44 | userId: 'user123', 45 | }); 46 | }); 47 | 48 | it('스페이스가 최대 제한을 초과하지 않으면 예외를 던지지 않아야 한다', async () => { 49 | (spaceModel.countDocuments as jest.Mock).mockResolvedValue( 50 | MAX_SPACES - 1, 51 | ); 52 | 53 | await expect( 54 | spaceValidation.validateSpaceLimit('user123'), 55 | ).resolves.not.toThrow(); 56 | 57 | expect(spaceModel.countDocuments).toHaveBeenCalledWith({ 58 | userId: 'user123', 59 | }); 60 | }); 61 | }); 62 | 63 | describe('validateParentNodeExists', () => { 64 | it('parentContextNodeId가 없으면 예외를 던지지 않아야 한다', async () => { 65 | await expect( 66 | spaceValidation.validateParentNodeExists(null), 67 | ).resolves.not.toThrow(); 68 | 69 | expect(spaceModel.findOne).not.toHaveBeenCalled(); 70 | }); 71 | 72 | it('parentContextNodeId가 존재하지만 스페이스를 찾지 못하면 예외를 던져야 한다', async () => { 73 | (spaceModel.findOne as jest.Mock).mockResolvedValue(null); 74 | 75 | await expect( 76 | spaceValidation.validateParentNodeExists('parent-id'), 77 | ).rejects.toThrow(ERROR_MESSAGES.SPACE.PARENT_NOT_FOUND); 78 | 79 | expect(spaceModel.findOne).toHaveBeenCalledWith({ 80 | id: 'parent-id', 81 | }); 82 | }); 83 | 84 | it('parentContextNodeId가 존재하고 스페이스를 찾으면 예외를 던지지 않아야 한다', async () => { 85 | (spaceModel.findOne as jest.Mock).mockResolvedValue({ 86 | id: 'parent-id', 87 | }); 88 | 89 | await expect( 90 | spaceValidation.validateParentNodeExists('parent-id'), 91 | ).resolves.not.toThrow(); 92 | 93 | expect(spaceModel.findOne).toHaveBeenCalledWith({ 94 | id: 'parent-id', 95 | }); 96 | }); 97 | }); 98 | 99 | describe('validateSpaceExists', () => { 100 | it('urlPath에 해당하는 스페이스가 없으면 예외를 던져야 한다', async () => { 101 | (spaceModel.findOne as jest.Mock).mockResolvedValue(null); 102 | 103 | await expect( 104 | spaceValidation.validateSpaceExists('test-path'), 105 | ).rejects.toThrow(ERROR_MESSAGES.SPACE.NOT_FOUND); 106 | 107 | expect(spaceModel.findOne).toHaveBeenCalledWith({ 108 | urlPath: 'test-path', 109 | }); 110 | }); 111 | 112 | it('urlPath에 해당하는 스페이스가 있으면 예외를 던지지 않고 해당 스페이스를 반환해야 한다', async () => { 113 | const mockSpace = { id: 'space-id', name: 'Test Space' }; 114 | (spaceModel.findOne as jest.Mock).mockResolvedValue(mockSpace); 115 | 116 | const result = await spaceValidation.validateSpaceExists('test-path'); 117 | 118 | expect(spaceModel.findOne).toHaveBeenCalledWith({ 119 | urlPath: 'test-path', 120 | }); 121 | expect(result).toEqual(mockSpace); 122 | }); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /packages/backend/src/space/space.validation.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectModel } from '@nestjs/mongoose'; 3 | import { Model } from 'mongoose'; 4 | 5 | import { ERROR_MESSAGES } from '../common/constants/error.message.constants'; 6 | import { MAX_SPACES } from '../common/constants/space.constants'; 7 | import { SpaceDocument } from './space.schema'; 8 | 9 | @Injectable() 10 | export class SpaceValidation { 11 | constructor( 12 | @InjectModel(SpaceDocument.name) 13 | private readonly spaceModel: Model, 14 | ) {} 15 | 16 | async validateSpaceLimit(userId: string) { 17 | const spaceCount = await this.spaceModel.countDocuments({ userId }); 18 | 19 | if (spaceCount >= MAX_SPACES) { 20 | throw new Error(ERROR_MESSAGES.SPACE.LIMIT_EXCEEDED); 21 | } 22 | } 23 | 24 | async validateParentNodeExists(parentContextNodeId: string | null) { 25 | if (parentContextNodeId) { 26 | const space = await this.spaceModel.findOne({ 27 | id: parentContextNodeId, 28 | }); 29 | 30 | if (!space) { 31 | throw new Error(ERROR_MESSAGES.SPACE.PARENT_NOT_FOUND); 32 | } 33 | } 34 | } 35 | 36 | async validateSpaceExists(urlPath: string) { 37 | const space = await this.spaceModel.findOne({ urlPath }); 38 | 39 | if (!space) { 40 | throw new Error(ERROR_MESSAGES.SPACE.NOT_FOUND); 41 | } 42 | 43 | return space; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/backend/src/test/mock/mock.constants.ts: -------------------------------------------------------------------------------- 1 | export const MOCK_CONFIG = { 2 | ITERATIONS: { 3 | DEFAULT: 1000, 4 | CONCURRENT: 500, 5 | }, 6 | TEST_PREFIX: { 7 | SPACE: 'test-space-', 8 | NOTE: 'test-note-', 9 | CONCURRENT: 'concurrent-test-space-', 10 | }, 11 | } as const; 12 | -------------------------------------------------------------------------------- /packages/backend/src/test/mock/note.mock.data.ts: -------------------------------------------------------------------------------- 1 | import { MockNote } from '../types/performance.types'; 2 | 3 | export const noteMockData: MockNote[] = [ 4 | { 5 | id: 'c1ddbb14-689a-49ac-a2fc-a14aebb1c4ed', 6 | userId: 'test-user-id', 7 | name: 'test-name', 8 | content: 9 | 'AoIB+Ia//AoABwDujav1AwAGAQD4hr/8CgAEhPiGv/wKBAPthYyB+Ia//AoFAoT4hr/8CgcG7Iqk7Yq4h+6Nq/UDAAMJcGFyYWdyYXBoBwD4hr/8CgoGAQD4hr/8CgsDgfiGv/wKCgEABIH4hr/8Cg8BgfiGv/wKDgKE+Ia//AoWA+yXhIH4hr/8ChcChPiGv/wKGQPssq2B+Ia//AoaA4T4hr/8Ch0H64KY6rKMIIH4hr/8CiAChPiGv/wKIgTquLQggfiGv/wKJASE+Ia//AooA+usuIH4hr/8CikChPiGv/wKKwPsnpCB+Ia//AosAYT4hr/8Ci0D7Je0gfiGv/wKLgGE+Ia//AovA...', 10 | createdAt: new Date(), 11 | updatedAt: new Date(), 12 | }, 13 | { 14 | id: 'a4ac29f1-0504-43f4-b087-f47cf99b8186', 15 | userId: 'test-user-id', 16 | name: 'test-name', 17 | content: 'Different base64 encoded content...', 18 | createdAt: new Date(), 19 | updatedAt: new Date(), 20 | }, 21 | ]; 22 | -------------------------------------------------------------------------------- /packages/backend/src/test/mock/space.mock.data.ts: -------------------------------------------------------------------------------- 1 | import { MockSpace } from '../types/performance.types'; 2 | 3 | export const spaceMockData: MockSpace[] = [ 4 | { 5 | id: '69bd78b6-0755-4370-be44-3ab0adab011a', 6 | userId: 'test-user-id', 7 | name: 'test', 8 | edges: JSON.stringify({ 9 | u7c2xras28c: { 10 | from: '69bd78b6-0755-4370-be44-3ab0adab011a', 11 | to: '65ol60chol8', 12 | }, 13 | an5uhqliqpm: { 14 | from: '69bd78b6-0755-4370-be44-3ab0adab011a', 15 | to: 'ousnj3faubc', 16 | }, 17 | }), 18 | nodes: JSON.stringify({ 19 | '69bd78b6-0755-4370-be44-3ab0adab011a': { 20 | id: '69bd78b6-0755-4370-be44-3ab0adab011a', 21 | x: 0, 22 | y: 0, 23 | type: 'head', 24 | name: 'test space', 25 | src: '69bd78b6-0755-4370-be44-3ab0adab011a', 26 | }, 27 | '65ol60chol8': { 28 | id: '65ol60chol8', 29 | type: 'note', 30 | x: 283.50182393227146, 31 | y: -132.99774870089817, 32 | name: 'note', 33 | src: 'c1ddbb14-689a-49ac-a2fc-a14aebb1c4ed', 34 | }, 35 | }), 36 | createdAt: new Date(), 37 | updatedAt: new Date(), 38 | }, 39 | ]; 40 | -------------------------------------------------------------------------------- /packages/backend/src/test/note.test.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | Column, 4 | PrimaryGeneratedColumn, 5 | CreateDateColumn, 6 | UpdateDateColumn, 7 | } from 'typeorm'; 8 | 9 | @Entity('notes') 10 | export class Note { 11 | @PrimaryGeneratedColumn('uuid') 12 | id: string; 13 | 14 | @Column({ type: 'varchar', length: 255, nullable: false }) 15 | userId: string; 16 | 17 | @Column({ type: 'varchar', length: 255, nullable: false }) 18 | name: string; 19 | 20 | @Column({ type: 'text', nullable: true, default: null }) 21 | content: string | null; 22 | 23 | @CreateDateColumn({ type: 'timestamp' }) 24 | createdAt: Date; 25 | 26 | @UpdateDateColumn({ type: 'timestamp' }) 27 | updatedAt: Date; 28 | } 29 | -------------------------------------------------------------------------------- /packages/backend/src/test/space.test.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | Column, 4 | PrimaryGeneratedColumn, 5 | CreateDateColumn, 6 | UpdateDateColumn, 7 | Index, 8 | } from 'typeorm'; 9 | 10 | @Entity('spaces') 11 | export class Space { 12 | @PrimaryGeneratedColumn('uuid') 13 | id: string; 14 | 15 | @Column({ type: 'varchar', length: 255, nullable: true, default: null }) 16 | @Index() 17 | parentSpaceId: string | null; 18 | 19 | @Column({ type: 'varchar', length: 255, nullable: false }) 20 | userId: string; 21 | 22 | @Column({ type: 'varchar', length: 255, nullable: false }) 23 | name: string; 24 | 25 | @Column({ type: 'text', nullable: false }) 26 | edges: string; 27 | 28 | @Column({ type: 'text', nullable: false }) 29 | nodes: string; 30 | 31 | @CreateDateColumn({ type: 'timestamp' }) 32 | createdAt: Date; 33 | 34 | @UpdateDateColumn({ type: 'timestamp' }) 35 | updatedAt: Date; 36 | } 37 | -------------------------------------------------------------------------------- /packages/backend/src/test/test.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { MongooseModule } from '@nestjs/mongoose'; 4 | import { TestController } from './test.controller'; 5 | import { TestService } from './test.service'; 6 | import { Space as SpaceEntity } from './space.test.entity'; 7 | import { Note as NoteEntity } from './note.test.entity'; 8 | import { SpaceDocument, SpaceSchema } from 'src/space/space.schema'; 9 | import { NoteDocument, NoteSchema } from 'src/note/note.schema'; 10 | 11 | @Module({ 12 | imports: [ 13 | TypeOrmModule.forFeature([SpaceEntity, NoteEntity]), 14 | MongooseModule.forFeature([ 15 | { name: SpaceDocument.name, schema: SpaceSchema }, 16 | { name: NoteDocument.name, schema: NoteSchema }, 17 | ]), 18 | ], 19 | controllers: [TestController], 20 | providers: [TestService], 21 | exports: [TestService], 22 | }) 23 | export class TestModule {} 24 | -------------------------------------------------------------------------------- /packages/backend/src/test/test.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | import { InjectModel } from '@nestjs/mongoose'; 5 | import { Model } from 'mongoose'; 6 | import { Space as SpaceEntity } from './space.test.entity'; 7 | import { Note as NoteEntity } from './note.test.entity'; 8 | import { SpaceDocument } from 'src/space/space.schema'; 9 | import { NoteDocument } from 'src/note/note.schema'; 10 | 11 | @Injectable() 12 | export class TestService { 13 | constructor( 14 | @InjectRepository(SpaceEntity) 15 | private spaceRepository: Repository, 16 | @InjectRepository(NoteEntity) 17 | private noteRepository: Repository, 18 | @InjectModel(SpaceDocument.name) 19 | private spaceModel: Model, 20 | @InjectModel(NoteDocument.name) 21 | private noteModel: Model, 22 | ) {} 23 | 24 | async findSpaceByIdSQL(id: string) { 25 | return this.spaceRepository.findOne({ where: { id } }); 26 | } 27 | 28 | async findNoteByIdSQL(id: string) { 29 | return this.noteRepository.findOne({ where: { id } }); 30 | } 31 | 32 | async createSpaceSQL(data: any) { 33 | const space = this.spaceRepository.create(data); 34 | return this.spaceRepository.save(space); 35 | } 36 | 37 | async createNoteSQL(data: any) { 38 | const note = this.noteRepository.create(data); 39 | return this.noteRepository.save(note); 40 | } 41 | 42 | async findSpaceByIdMongo(id: string) { 43 | return this.spaceModel.findOne({ id }).exec(); 44 | } 45 | 46 | async findNoteByIdMongo(id: string) { 47 | return this.noteModel.findOne({ id }).exec(); 48 | } 49 | 50 | async createSpaceMongo(data: any) { 51 | const space = new this.spaceModel(data); 52 | return space.save(); 53 | } 54 | 55 | async createNoteMongo(data: any) { 56 | const note = new this.noteModel(data); 57 | return note.save(); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /packages/backend/src/test/types/performance.types.ts: -------------------------------------------------------------------------------- 1 | export interface PerformanceResult { 2 | mysqlDuration: number; 3 | mongoDuration: number; 4 | difference: number; 5 | } 6 | 7 | export interface MockSpace { 8 | id: string; 9 | userId: string; 10 | name: string; 11 | edges: string; 12 | nodes: string; 13 | createdAt: Date; 14 | updatedAt: Date; 15 | } 16 | 17 | export interface MockNote { 18 | id: string; 19 | userId: string; 20 | name: string; 21 | content: string; 22 | createdAt: Date; 23 | updatedAt: Date; 24 | } 25 | -------------------------------------------------------------------------------- /packages/backend/src/yjs/yjs.gateway.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { YjsGateway } from './yjs.gateway'; 3 | import { CollaborativeService } from '../collaborative/collaborative.service'; 4 | import { WebSocket } from 'ws'; 5 | import { Request } from 'express'; 6 | import { ERROR_MESSAGES } from '../common/constants/error.message.constants'; 7 | import { WebsocketStatus } from '../common/constants/websocket.constants'; 8 | 9 | describe('YjsGateway', () => { 10 | let gateway: YjsGateway; 11 | let collaborativeService: CollaborativeService; 12 | 13 | beforeEach(async () => { 14 | const mockCollaborativeService = { 15 | findByNote: jest.fn(), 16 | findBySpace: jest.fn(), 17 | updateByNote: jest.fn(), 18 | updateBySpace: jest.fn(), 19 | }; 20 | 21 | const module: TestingModule = await Test.createTestingModule({ 22 | providers: [ 23 | YjsGateway, 24 | { 25 | provide: CollaborativeService, 26 | useValue: mockCollaborativeService, 27 | }, 28 | ], 29 | }).compile(); 30 | 31 | gateway = module.get(YjsGateway); 32 | collaborativeService = 33 | module.get(CollaborativeService); 34 | }); 35 | 36 | describe('handleConnection', () => { 37 | it('유효하지 않은 URL로 WebSocket 연결을 닫아야 한다', async () => { 38 | const connection = { 39 | close: jest.fn(), 40 | } as unknown as WebSocket; 41 | 42 | const request = { 43 | url: '/invalid-url', 44 | } as Request; 45 | 46 | await gateway.handleConnection(connection, request); 47 | 48 | expect(connection.close).toHaveBeenCalledWith( 49 | WebsocketStatus.POLICY_VIOLATION, 50 | ERROR_MESSAGES.SOCKET.INVALID_URL, 51 | ); 52 | }); 53 | 54 | it('유효한 노트 URL로 WebSocket 연결을 초기화해야 한다', async () => { 55 | const connection = { 56 | close: jest.fn(), 57 | } as unknown as WebSocket; 58 | 59 | const request = { 60 | url: '/note/123', 61 | } as Request; 62 | 63 | (collaborativeService.findByNote as jest.Mock).mockResolvedValue({ 64 | id: '123', 65 | }); 66 | 67 | await gateway.handleConnection(connection, request); 68 | 69 | expect(collaborativeService.findByNote).toHaveBeenCalledWith('123'); 70 | }); 71 | 72 | it('유효한 스페이스 URL로 WebSocket 연결을 초기화해야 한다', async () => { 73 | const connection = { 74 | close: jest.fn(), 75 | } as unknown as WebSocket; 76 | 77 | const request = { 78 | url: '/space/123', 79 | } as Request; 80 | 81 | (collaborativeService.findBySpace as jest.Mock).mockResolvedValue({ 82 | id: '123', 83 | }); 84 | 85 | await gateway.handleConnection(connection, request); 86 | 87 | expect(collaborativeService.findBySpace).toHaveBeenCalledWith('123'); 88 | }); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /packages/backend/src/yjs/yjs.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { CollaborativeModule } from '../collaborative/collaborative.module'; 4 | import { NoteModule } from '../note/note.module'; 5 | import { SpaceModule } from '../space/space.module'; 6 | import { YjsGateway } from './yjs.gateway'; 7 | 8 | @Module({ 9 | imports: [SpaceModule, NoteModule, CollaborativeModule], 10 | providers: [YjsGateway], 11 | }) 12 | export class YjsModule {} 13 | -------------------------------------------------------------------------------- /packages/backend/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /packages/backend/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/backend/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": ".", 13 | "incremental": true, 14 | "esModuleInterop": true, 15 | "skipLibCheck": true, 16 | "strictNullChecks": true, 17 | "noImplicitAny": false, 18 | "strictBindCallApply": false, 19 | "forceConsistentCasingInFileNames": false, 20 | "noFallthroughCasesInSwitch": false, 21 | "noEmit": false 22 | }, 23 | "include": ["src/**/*", "../../test"], 24 | "exclude": ["node_modules", "dist"] 25 | } 26 | -------------------------------------------------------------------------------- /packages/frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .git 3 | .gitignore 4 | *.md 5 | dist -------------------------------------------------------------------------------- /packages/frontend/.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 | *storybook.log 27 | -------------------------------------------------------------------------------- /packages/frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "prettier-plugin-tailwindcss", 4 | "@trivago/prettier-plugin-sort-imports" 5 | ], 6 | "importOrder": ["^react", "", "^@/(.*)$", "^[./]"], 7 | "importOrderSeparation": true, 8 | "importOrderSortSpecifiers": true, 9 | "endOfLine": "auto" 10 | } 11 | -------------------------------------------------------------------------------- /packages/frontend/.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from "@storybook/react-vite"; 2 | 3 | const config: StorybookConfig = { 4 | stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], 5 | addons: [ 6 | "@storybook/addon-onboarding", 7 | "@storybook/addon-essentials", 8 | "@chromatic-com/storybook", 9 | "@storybook/addon-interactions", 10 | ], 11 | framework: { 12 | name: "@storybook/react-vite", 13 | options: {}, 14 | }, 15 | }; 16 | export default config; 17 | -------------------------------------------------------------------------------- /packages/frontend/.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import type { Preview } from "@storybook/react"; 2 | 3 | const preview: Preview = { 4 | parameters: { 5 | controls: { 6 | matchers: { 7 | color: /(background|color)$/i, 8 | date: /Date$/i, 9 | }, 10 | }, 11 | }, 12 | }; 13 | 14 | export default preview; 15 | -------------------------------------------------------------------------------- /packages/frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine AS builder 2 | WORKDIR /app 3 | RUN apk add --no-cache python3 make g++ && npm install -g pnpm 4 | COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ 5 | COPY ./tsconfig.json ./ 6 | COPY ./packages/shared/ ./packages/shared/ 7 | COPY ./packages/frontend/ ./packages/frontend/ 8 | 9 | RUN HUSKY=0 pnpm install --no-frozen-lockfile 10 | COPY ./packages/frontend/src ./packages/frontend/src 11 | COPY ./packages/frontend/public ./packages/frontend/public 12 | COPY ./packages/frontend/index.html ./packages/frontend 13 | COPY ./packages/frontend/vite.config.ts ./packages/frontend 14 | RUN cd ./packages/frontend && pnpm build 15 | 16 | 17 | FROM nginx:alpine AS production 18 | 19 | RUN echo 'events { worker_connections 1024; }' > /etc/nginx/nginx.conf && \ 20 | echo 'http {' >> /etc/nginx/nginx.conf && \ 21 | echo ' include /etc/nginx/mime.types;' >> /etc/nginx/nginx.conf && \ 22 | echo ' default_type application/octet-stream;' >> /etc/nginx/nginx.conf && \ 23 | echo ' server_tokens off;' >> /etc/nginx/nginx.conf && \ 24 | echo ' access_log off;' >> /etc/nginx/nginx.conf && \ 25 | echo ' error_log stderr crit;' >> /etc/nginx/nginx.conf && \ 26 | echo ' include /etc/nginx/conf.d/*.conf;' >> /etc/nginx/nginx.conf && \ 27 | echo '}' >> /etc/nginx/nginx.conf 28 | 29 | COPY --from=builder /app/packages/frontend/nginx.conf /etc/nginx/conf.d/default.conf 30 | COPY --from=builder /app/packages/frontend/dist /usr/share/nginx/html 31 | 32 | EXPOSE 80 33 | CMD ["nginx", "-g", "daemon off;"] -------------------------------------------------------------------------------- /packages/frontend/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/index.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } 22 | -------------------------------------------------------------------------------- /packages/frontend/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import reactHooks from "eslint-plugin-react-hooks"; 3 | import reactRefresh from "eslint-plugin-react-refresh"; 4 | import storybook from "eslint-plugin-storybook"; 5 | import globals from "globals"; 6 | import tseslint from "typescript-eslint"; 7 | 8 | import rootEsLint from "../../eslint.config.mjs"; 9 | 10 | export default tseslint.config( 11 | { ignores: ["dist"] }, 12 | { 13 | extends: [ 14 | js.configs.recommended, 15 | ...tseslint.configs.recommended, 16 | ...rootEsLint, 17 | ], 18 | files: ["**/*.{ts,tsx}"], 19 | languageOptions: { 20 | ecmaVersion: 2020, 21 | globals: globals.browser, 22 | }, 23 | plugins: { 24 | "react-hooks": reactHooks, 25 | "react-refresh": reactRefresh, 26 | }, 27 | settings: { 28 | "import/resolver": { 29 | typescript: { 30 | project: [ 31 | "./packages/frontend/tsconfig.app.json", 32 | "./packages/frontend/tsconfig.node.json", 33 | ], 34 | }, 35 | }, 36 | }, 37 | rules: { 38 | ...reactHooks.configs.recommended.rules, 39 | "no-shadow": "off", 40 | "import/no-absolute-path": "warn", 41 | "import/no-unresolved": "warn", 42 | "react-refresh/only-export-components": [ 43 | "warn", 44 | { allowConstantExport: true }, 45 | ], 46 | "import/prefer-default-export": "off", 47 | "import/extensions": "off", 48 | }, 49 | }, 50 | { 51 | extends: storybook.configs["flat/recommended"], 52 | files: ["**/*.stories.{ts,tsx,js,jsx}"], 53 | plugins: { 54 | storybook, 55 | }, 56 | rules: { 57 | "storybook/story-exports": "error", 58 | }, 59 | }, 60 | ); 61 | -------------------------------------------------------------------------------- /packages/frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | HoneyFlow | 끈적끈적 협업 지식 관리 8 | 9 | 10 | 11 | 15 | 16 | 17 | 18 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /packages/frontend/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | listen [::]:80; 4 | server_name honeyflow.life www.honeyflow.life; 5 | 6 | root /usr/share/nginx/html; 7 | index index.html; 8 | 9 | 10 | # 보안 헤더 설정 11 | add_header X-Frame-Options "SAMEORIGIN"; 12 | add_header X-XSS-Protection "1; mode=block"; 13 | add_header X-Content-Type-Options "nosniff"; 14 | add_header Referrer-Policy "strict-origin-when-cross-origin"; 15 | add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws://www.honeyflow.life;"; 16 | 17 | 18 | # gzip 설정 19 | gzip on; 20 | gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; 21 | gzip_comp_level 6; 22 | gzip_min_length 1000; 23 | 24 | # SPA 라우팅을 위한 설정 25 | location / { 26 | try_files $uri $uri/ /index.html; 27 | expires -1; 28 | } 29 | # Backend API 설정 30 | location /api/ { 31 | proxy_pass http://backend:3000; 32 | proxy_set_header Host $host; 33 | proxy_set_header X-Real-IP $remote_addr; 34 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 35 | proxy_set_header X-Forwarded-Proto $scheme; 36 | add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, OPTIONS'; 37 | add_header Access-Control-Allow-Headers 'Origin, Content-Type, Accept, Authorization'; 38 | } 39 | # Backend Socket 설정 40 | 41 | location /ws/ { 42 | proxy_pass http://backend:9001; 43 | proxy_set_header Upgrade $http_upgrade; 44 | proxy_set_header Connection "upgrade"; 45 | proxy_set_header Host $host; 46 | proxy_set_header X-Real-IP $remote_addr; 47 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 48 | proxy_set_header X-Forwarded-Proto $scheme; 49 | add_header Access-Control-Allow-Origin *; 50 | add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, OPTIONS'; 51 | add_header Access-Control-Allow-Headers 'Origin, Content-Type, Accept, Authorization'; 52 | } 53 | 54 | # 정적 파일 캐싱 55 | location /assets/ { 56 | expires 1y; 57 | add_header Cache-Control "public, no-transform"; 58 | } 59 | 60 | # 헬스체크 엔드포인트 61 | location /health { 62 | access_log off; 63 | return 200 'healthy\n'; 64 | } 65 | 66 | # favicon.ico 처리 67 | location = /favicon.ico { 68 | access_log off; 69 | expires 1d; 70 | } 71 | 72 | # 404 에러 처리 73 | error_page 404 /index.html; 74 | } -------------------------------------------------------------------------------- /packages/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview", 11 | "storybook": "storybook dev -p 6006", 12 | "build-storybook": "storybook build", 13 | "playwright:test": "playwright test" 14 | }, 15 | "dependencies": { 16 | "@codemirror/commands": "^6.7.1", 17 | "@codemirror/language-data": "^6.5.1", 18 | "@codemirror/theme-one-dark": "^6.1.2", 19 | "@codemirror/view": "^6.35.0", 20 | "@milkdown/kit": "^7.5.5", 21 | "@milkdown/plugin-collab": "^7.5.0", 22 | "@milkdown/react": "^7.5.0", 23 | "@milkdown/theme-nord": "^7.5.0", 24 | "@prosemirror-adapter/react": "^0.2.6", 25 | "@radix-ui/react-context-menu": "^2.2.2", 26 | "@radix-ui/react-dialog": "^1.1.2", 27 | "@radix-ui/react-dropdown-menu": "^2.1.2", 28 | "@radix-ui/react-hover-card": "^1.1.2", 29 | "@radix-ui/react-label": "^2.1.0", 30 | "@radix-ui/react-popover": "^1.1.2", 31 | "@radix-ui/react-slot": "^1.1.0", 32 | "class-variance-authority": "^0.7.0", 33 | "clsx": "^2.1.1", 34 | "codemirror": "^6.0.1", 35 | "konva": "^9.3.16", 36 | "lucide-react": "^0.454.0", 37 | "react": "^18.3.1", 38 | "react-dom": "^18.3.1", 39 | "react-konva": "^18.2.10", 40 | "react-konva-utils": "^1.0.6", 41 | "react-router-dom": "^6.28.0", 42 | "shared": "workspace:*", 43 | "tailwind-merge": "^2.5.4", 44 | "tailwindcss-animate": "^1.0.7", 45 | "y-websocket": "^2.0.4", 46 | "yjs": "^13.6.20" 47 | }, 48 | "devDependencies": { 49 | "@chromatic-com/storybook": "^3.2.2", 50 | "@eslint/js": "^9.13.0", 51 | "@playwright/test": "^1.48.2", 52 | "@storybook/addon-essentials": "^8.4.2", 53 | "@storybook/addon-interactions": "^8.4.2", 54 | "@storybook/addon-onboarding": "^8.4.2", 55 | "@storybook/addons": "^7.6.17", 56 | "@storybook/blocks": "^8.4.2", 57 | "@storybook/react": "^8.4.2", 58 | "@storybook/react-vite": "^8.4.2", 59 | "@storybook/test": "^8.4.2", 60 | "@tailwindcss/typography": "^0.5.15", 61 | "@trivago/prettier-plugin-sort-imports": "^4.3.0", 62 | "@types/node": "^22.9.0", 63 | "@types/react": "^18.3.12", 64 | "@types/react-dom": "^18.3.1", 65 | "@vitejs/plugin-react": "^4.3.3", 66 | "autoprefixer": "^10.4.20", 67 | "eslint": "^9.13.0", 68 | "eslint-import-resolver-typescript": "^3.6.3", 69 | "eslint-plugin-react-hooks": "^5.0.0", 70 | "eslint-plugin-react-refresh": "^0.4.14", 71 | "eslint-plugin-storybook": "^0.11.0", 72 | "globals": "^15.11.0", 73 | "postcss": "^8.4.47", 74 | "prettier-plugin-tailwindcss": "^0.6.8", 75 | "storybook": "^8.4.2", 76 | "tailwindcss": "^3.4.14", 77 | "typescript": "~5.6.2", 78 | "typescript-eslint": "^8.11.0", 79 | "vite": "^5.4.10", 80 | "vitest": "^2.1.4" 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /packages/frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /packages/frontend/public/PretendardVariable.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web29-honeyflow/cf3a0287e177b189819cf928e65d74b1780678a7/packages/frontend/public/PretendardVariable.woff2 -------------------------------------------------------------------------------- /packages/frontend/public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /packages/frontend/public/home-bg.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/frontend/public/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web29-honeyflow/cf3a0287e177b189819cf928e65d74b1780678a7/packages/frontend/public/og-image.png -------------------------------------------------------------------------------- /packages/frontend/src/App.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | width: 100%; 4 | height: 100%; 5 | padding: 0; 6 | margin: 0; 7 | } 8 | 9 | #root { 10 | width: 100%; 11 | min-height: 100%; 12 | height: 100%; 13 | } 14 | -------------------------------------------------------------------------------- /packages/frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter, Route, Routes } from "react-router-dom"; 2 | 3 | import "./App.css"; 4 | import Editor from "./components/note/Editor.tsx"; 5 | import { PromptDialogPortal } from "./lib/prompt-dialog.tsx"; 6 | import Home from "./pages/Home.tsx"; 7 | import NotFoundPage from "./pages/NotFound.tsx"; 8 | import SpacePage from "./pages/Space.tsx"; 9 | 10 | function App() { 11 | return ( 12 | <> 13 | 14 | 15 | 16 | } /> 17 | } /> 18 | } /> 19 | } /> 20 | 21 | 22 | 23 | ); 24 | } 25 | 26 | export default App; 27 | -------------------------------------------------------------------------------- /packages/frontend/src/api/constants.ts: -------------------------------------------------------------------------------- 1 | export const API_V1_URL = import.meta.env.DEV 2 | ? "http://localhost/api/v1" 3 | : "/api/v1"; 4 | 5 | export const API_V2_URL = import.meta.env.DEV 6 | ? "http://localhost/api/v2" 7 | : "/api/v2"; 8 | 9 | export const WS_URL = import.meta.env.DEV ? "ws://localhost/ws" : "/ws"; 10 | -------------------------------------------------------------------------------- /packages/frontend/src/api/http.ts: -------------------------------------------------------------------------------- 1 | // TODO: 에러 타입 핸들링 2 | class HttpResponseError extends Error { 3 | constructor(response: Response) { 4 | super(`HTTP error: ${response.status} ${response.statusText}`); 5 | } 6 | } 7 | 8 | type HttpResponse = { 9 | data: T; 10 | status: number; 11 | statusText: string; 12 | headers: Record; 13 | config: RequestInit; 14 | request: Request; 15 | }; 16 | 17 | async function http( 18 | url: string, 19 | config: RequestInit, 20 | ): Promise> { 21 | const request = new Request(url, { 22 | ...config, 23 | headers: { 24 | "Content-Type": "application/json", 25 | ...config.headers, 26 | }, 27 | }); 28 | const response = await fetch(request); 29 | 30 | if (!response.ok) { 31 | throw new HttpResponseError(response); 32 | } 33 | 34 | // json으로 한정 35 | const data = config.method === "DELETE" ? null : await response.json(); 36 | 37 | return { 38 | data, 39 | status: response.status, 40 | statusText: response.statusText, 41 | headers: Object.fromEntries(response.headers.entries()), 42 | config, 43 | request, 44 | }; 45 | } 46 | 47 | http.get = function (url: string, config?: Omit) { 48 | return http(url, { ...config, method: "GET" }); 49 | }; 50 | http.post = function (url: string, config?: Omit) { 51 | return http(url, { ...config, method: "POST" }); 52 | }; 53 | http.put = function (url: string, config?: Omit) { 54 | return http(url, { ...config, method: "PUT" }); 55 | }; 56 | http.patch = function (url: string, config?: Omit) { 57 | return http(url, { ...config, method: "PATCH" }); 58 | }; 59 | http.delete = function (url: string, config?: Omit) { 60 | return http(url, { ...config, method: "DELETE" }); 61 | }; 62 | 63 | export type { HttpResponse }; 64 | 65 | export { HttpResponseError }; 66 | 67 | export default http; 68 | -------------------------------------------------------------------------------- /packages/frontend/src/api/note.ts: -------------------------------------------------------------------------------- 1 | import { API_V1_URL } from "./constants"; 2 | import http from "./http"; 3 | 4 | type CreateNoteRequestBody = { 5 | userId: string; 6 | noteName: string; 7 | }; 8 | 9 | type CreateNoteResponseBody = { 10 | urlPath: string; 11 | }; 12 | 13 | export async function createNote(body: CreateNoteRequestBody) { 14 | const response = await http.post( 15 | `${API_V1_URL}/note`, 16 | { body: JSON.stringify(body) }, 17 | ); 18 | return response.data; 19 | } 20 | -------------------------------------------------------------------------------- /packages/frontend/src/api/space.ts: -------------------------------------------------------------------------------- 1 | import { BreadcrumbItem } from "shared/types"; 2 | 3 | import { API_V1_URL, API_V2_URL } from "./constants"; 4 | import http from "./http"; 5 | 6 | type CreateSpaceRequestBody = { 7 | userId: string; 8 | spaceName: string; 9 | parentContextNodeId: string | null; 10 | }; 11 | 12 | type CreateSpaceResponseBody = { 13 | urlPath: string; 14 | }; 15 | 16 | export async function createSpace(body: CreateSpaceRequestBody) { 17 | const response = await http.post( 18 | `${API_V2_URL}/space`, 19 | { body: JSON.stringify(body) }, 20 | ); 21 | return response.data; 22 | } 23 | 24 | type GetBreadcrumbResponseBody = BreadcrumbItem[]; 25 | 26 | export async function getBreadcrumbOfSpace(spaceUrlPath: string) { 27 | const response = await http.get( 28 | `${API_V1_URL}/space/breadcrumb/${spaceUrlPath}`, 29 | ); 30 | return response.data; 31 | } 32 | 33 | export async function updateSpace(id: string, body: CreateSpaceRequestBody) { 34 | const response = await http.put( 35 | `${API_V1_URL}/space/${id}`, 36 | { body: JSON.stringify(body) }, 37 | ); 38 | return response.data; 39 | } 40 | 41 | export async function deleteSpace(spaceId: string) { 42 | const response = await http.delete(`${API_V1_URL}/space/${spaceId}`); 43 | return response; 44 | } 45 | -------------------------------------------------------------------------------- /packages/frontend/src/assets/error-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /packages/frontend/src/assets/shapes.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * SVG 도형을 그리기 위한 path 데이터 문자열 3 | * 4 | * M: 시작점 이동 (Move to) 5 | * Q: 베지어 곡선 (Quadratic Bezier curve) 6 | * Z: 경로 닫기 (Close path) 7 | * 8 | * 값 형식: x y 좌표 (0~100 범위의 상대값) 9 | */ 10 | 11 | export const circle = ` 12 | M 50 0 13 | Q 85 0, 92.5 25 14 | Q 110 50, 92.5 75 15 | Q 85 100, 50 100 16 | Q 15 100, 7.5 75 17 | Q -10 50, 7.5 25 18 | Q 15 0, 50 0 19 | Z 20 | `; 21 | 22 | export const hexagon = ` 23 | M 50 0 24 | Q 71.65 12.5, 93.3 25 25 | Q 93.3 50, 93.3 75 26 | Q 71.65 87.5, 50 100 27 | Q 28.35 87.5, 6.7 75 28 | Q 6.7 50, 6.7 25 29 | Q 28.35 12.5, 50 0 30 | Z 31 | `; 32 | -------------------------------------------------------------------------------- /packages/frontend/src/components/Edge.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { Circle, Group, Line, Text } from "react-konva"; 3 | 4 | import Konva from "konva"; 5 | import { KonvaEventObject } from "konva/lib/Node"; 6 | import type { Edge } from "shared/types"; 7 | 8 | type EdgeProps = Edge & Konva.LineConfig; 9 | 10 | const BUTTON_RADIUS = 12; 11 | 12 | type EdgeEditButtonProps = { 13 | points: number[]; 14 | onTap: (edgeId: string) => void; 15 | }; 16 | 17 | function EdgeEditButton({ points, onTap }: EdgeEditButtonProps) { 18 | const [isTouch, setIsTouch] = useState(false); 19 | 20 | useEffect(() => { 21 | setIsTouch("ontouchstart" in window || navigator.maxTouchPoints > 0); 22 | }, []); 23 | 24 | if (!isTouch || points.length < 4) return null; 25 | 26 | const handleTap = (e: KonvaEventObject) => { 27 | const targetGroup = e.target 28 | .findAncestor("Group") 29 | .findAncestor("Group") as Konva.Group; 30 | 31 | if (!targetGroup) return; 32 | 33 | const targetEdge = targetGroup.children.find( 34 | (konvaNode) => konvaNode.attrs.name === "edge", 35 | ); 36 | 37 | if (!targetEdge) return; 38 | 39 | onTap(targetEdge.attrs.id); 40 | }; 41 | 42 | const middleX = (points[0] + points[2]) / 2; 43 | const middleY = (points[1] + points[3]) / 2; 44 | 45 | return ( 46 | 47 | 53 | 64 | 65 | ); 66 | } 67 | 68 | function calculateOffsets( 69 | from: { x: number; y: number }, 70 | to: { x: number; y: number }, 71 | radius: number, 72 | ) { 73 | const dx = to.x - from.x; 74 | const dy = to.y - from.y; 75 | const distance = Math.sqrt(dx * dx + dy * dy); 76 | 77 | const offsetX = (dx / distance) * radius; 78 | const offsetY = (dy / distance) * radius; 79 | 80 | return { offsetX, offsetY }; 81 | } 82 | 83 | export default function Edge({ 84 | from, 85 | to, 86 | id, 87 | onContextMenu, 88 | onDelete, 89 | ...rest 90 | }: EdgeProps) { 91 | const [points, setPoints] = useState([]); 92 | const [isHovered, setIsHovered] = useState(false); 93 | const RADIUS = 64; 94 | 95 | useEffect(() => { 96 | if (from && to) { 97 | const { offsetX, offsetY } = calculateOffsets(from, to, RADIUS); 98 | 99 | setPoints([ 100 | from.x + offsetX, 101 | from.y + offsetY, 102 | to.x - offsetX, 103 | to.y - offsetY, 104 | ]); 105 | } 106 | }, [from, to]); 107 | 108 | return ( 109 | setIsHovered(true)} 111 | onMouseLeave={() => setIsHovered(false)} 112 | onContextMenu={onContextMenu} 113 | > 114 | 121 | 129 | 130 | 131 | ); 132 | } 133 | -------------------------------------------------------------------------------- /packages/frontend/src/components/ErrorSection.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | import buzzyLogo from "@/assets/error-logo.svg"; 4 | 5 | type ErrorSectionProps = { 6 | description: string; 7 | RestoreActions?: () => ReactNode; 8 | }; 9 | 10 | export default function ErrorSection({ 11 | description, 12 | RestoreActions, 13 | }: ErrorSectionProps) { 14 | return ( 15 |
16 |
17 | 18 |
19 |

{description}

20 |
21 | {RestoreActions && ( 22 |
23 | 24 |
25 | )} 26 |
27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /packages/frontend/src/components/PointerCursor.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useCallback, useLayoutEffect, useRef } from "react"; 2 | import { Group, Label, Path, Tag, Text } from "react-konva"; 3 | 4 | import Konva from "konva"; 5 | 6 | type PointerCursorProps = { 7 | position?: { 8 | x: number; 9 | y: number; 10 | }; 11 | color: string; 12 | label?: string; 13 | }; 14 | 15 | const PointerCursor = memo(({ color, position, label }: PointerCursorProps) => { 16 | const ref = useRef(null); 17 | 18 | const tween = useCallback((position: { x: number; y: number }) => { 19 | const { current } = ref; 20 | 21 | if (!current) { 22 | return; 23 | } 24 | 25 | if (current.visible()) { 26 | current.to({ 27 | x: position.x, 28 | y: position.y, 29 | duration: 0.1, 30 | }); 31 | 32 | return; 33 | } 34 | 35 | current.visible(true); 36 | current.position(position); 37 | }, []); 38 | 39 | useLayoutEffect(() => { 40 | if (position?.x !== undefined && position?.y !== undefined) { 41 | tween({ x: position.x, y: position.y }); 42 | } 43 | }, [position?.x, position?.y, tween]); 44 | 45 | if (!position) { 46 | return null; 47 | } 48 | 49 | return ( 50 | // https://github.com/steveruizok/perfect-cursors/blob/main/example/src/components/Cursor.tsx 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 66 | 70 | 71 | 72 | {label && ( 73 | 83 | )} 84 | 85 | ); 86 | }); 87 | 88 | export default PointerCursor; 89 | -------------------------------------------------------------------------------- /packages/frontend/src/components/PointerLayer.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from "react"; 2 | import { Layer } from "react-konva"; 3 | 4 | import Konva from "konva"; 5 | import { WebsocketProvider } from "y-websocket"; 6 | 7 | import useYjsSpaceAwarenessStates from "@/hooks/useYjsSpaceAwareness"; 8 | import { throttle } from "@/lib/utils"; 9 | import { useYjsStore } from "@/store/yjs"; 10 | 11 | import PointerCursor from "./PointerCursor"; 12 | 13 | export default function PointerLayer() { 14 | const layerRef = useRef(null); 15 | 16 | const { yProvider } = useYjsStore(); 17 | const awareness = (yProvider as WebsocketProvider | undefined)?.awareness; 18 | const { states: userStates, setLocalStateField } = 19 | useYjsSpaceAwarenessStates(awareness); 20 | 21 | const setLocalPointerPosition = useCallback( 22 | (position?: { x: number; y: number }) => { 23 | setLocalStateField?.("pointer", position || undefined); 24 | }, 25 | [setLocalStateField], 26 | ); 27 | 28 | useEffect(() => { 29 | const layer = layerRef.current; 30 | const stage = layer?.getStage(); 31 | 32 | if (!stage) { 33 | return undefined; 34 | } 35 | 36 | const handlePointerMove = throttle(() => { 37 | const pointerPosition = stage.getRelativePointerPosition(); 38 | setLocalPointerPosition(pointerPosition || undefined); 39 | }, 100); 40 | 41 | const handlePointerLeave = () => { 42 | setLocalPointerPosition(undefined); 43 | }; 44 | 45 | stage.on("pointermove dragmove", handlePointerMove); 46 | window.addEventListener("pointerleave", handlePointerLeave); 47 | window.addEventListener("pointerout", handlePointerLeave); 48 | return () => { 49 | stage.off("pointermove dragmove", handlePointerMove); 50 | window.removeEventListener("pointerleave", handlePointerLeave); 51 | window.removeEventListener("pointerout", handlePointerLeave); 52 | }; 53 | }, [setLocalPointerPosition]); 54 | 55 | return ( 56 | 57 | {userStates && 58 | [...userStates].map(([clientId, { color, pointer }]) => { 59 | if (clientId === awareness?.clientID) { 60 | return null; 61 | } 62 | 63 | const pointerColor = color; 64 | 65 | return ( 66 | pointer && ( 67 | 73 | ) 74 | ); 75 | })} 76 | 77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /packages/frontend/src/components/SpaceBreadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "react-router-dom"; 2 | 3 | import { BreadcrumbItem as BreadcrumbItemData } from "shared/types"; 4 | 5 | import { 6 | Breadcrumb, 7 | BreadcrumbEllipsis, 8 | BreadcrumbItem, 9 | BreadcrumbLink, 10 | BreadcrumbList, 11 | BreadcrumbPage, 12 | BreadcrumbSeparator, 13 | } from "./ui/breadcrumb"; 14 | import { 15 | DropdownMenu, 16 | DropdownMenuContent, 17 | DropdownMenuItem, 18 | DropdownMenuTrigger, 19 | } from "./ui/dropdown-menu"; 20 | 21 | function splitSpacePaths( 22 | spacePaths: BreadcrumbItemData[], 23 | itemCountToDisplay: number, 24 | ) { 25 | const itemCount = spacePaths.length; 26 | 27 | // 처음 스페이스는 무조건 보여준다. 28 | const firstSpacePath = itemCount > 1 ? spacePaths[0] : null; 29 | 30 | // 중간 스페이스들은 ...으로 표시하고, 클릭 시 드롭다운 메뉴로 보여준다. 31 | const hiddenSpacePaths = spacePaths.slice(1, -itemCountToDisplay + 1); 32 | 33 | // 마지막 (n-1)개 스페이스는 무조건 보여준다. 34 | const shownSpacePathCount = Math.min(itemCount, itemCountToDisplay) - 1; 35 | const shownSpacePaths = spacePaths.slice(-shownSpacePathCount); 36 | 37 | return [firstSpacePath, hiddenSpacePaths, shownSpacePaths] as const; 38 | } 39 | 40 | type HiddenItemsProps = { 41 | spacePaths: BreadcrumbItemData[]; 42 | }; 43 | 44 | function HiddenItems({ spacePaths }: HiddenItemsProps) { 45 | return ( 46 | <> 47 | 48 | 49 | 50 | 51 | 52 | 53 | {spacePaths.map(({ name, url }) => ( 54 | 55 | {name} 56 | 57 | ))} 58 | 59 | 60 | 61 | 62 | 63 | ); 64 | } 65 | 66 | type SpaceBreadcrumbItemProps = { 67 | spacePath: BreadcrumbItemData; 68 | isPage?: boolean; 69 | }; 70 | 71 | function SpaceBreadcrumbItem({ spacePath, isPage }: SpaceBreadcrumbItemProps) { 72 | if (isPage) { 73 | return ( 74 | 75 | 76 | {spacePath.name} 77 | 78 | 79 | ); 80 | } 81 | 82 | return ( 83 | <> 84 | 85 | 86 | 87 | {spacePath.name} 88 | 89 | 90 | 91 | 92 | 93 | ); 94 | } 95 | 96 | type SpaceBreadcrumbProps = { 97 | spacePaths: BreadcrumbItemData[]; 98 | itemCountToDisplay?: number; 99 | }; 100 | 101 | export default function SpaceBreadcrumb({ 102 | spacePaths, 103 | itemCountToDisplay = 3, 104 | }: SpaceBreadcrumbProps) { 105 | // [처음, (...중간...), 직전, 현재] 106 | const [firstSpacePath, hiddenSpacePaths, shownSpacePaths] = splitSpacePaths( 107 | spacePaths, 108 | itemCountToDisplay, 109 | ); 110 | 111 | return ( 112 | 113 | 114 | {firstSpacePath && } 115 | {hiddenSpacePaths.length > 0 && ( 116 | 117 | )} 118 | {shownSpacePaths.map((spacePath, index) => ( 119 | 124 | ))} 125 | 126 | 127 | ); 128 | } 129 | -------------------------------------------------------------------------------- /packages/frontend/src/components/SpaceShareAlertContent.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useState } from "react"; 2 | 3 | import { CheckIcon, ClipboardCopyIcon } from "lucide-react"; 4 | 5 | import { cn, copyToClipboard } from "@/lib/utils"; 6 | 7 | import { Button } from "./ui/button"; 8 | 9 | export default function SpaceShareAlertContent() { 10 | const [hasCopied, setHasCopied] = useState(false); 11 | const timeoutRef = useRef(null); 12 | 13 | const handleClickCopy = () => { 14 | async function copy() { 15 | await copyToClipboard(window.location.href); 16 | setHasCopied(true); 17 | 18 | if (timeoutRef.current) { 19 | window.clearTimeout(timeoutRef.current); 20 | } 21 | 22 | timeoutRef.current = window.setTimeout(() => { 23 | setHasCopied(false); 24 | }, 2000); 25 | } 26 | 27 | copy(); 28 | }; 29 | 30 | return ( 31 |
32 |
아래 주소를 공유해주세요
33 |
34 |         {window.location.href}
35 |       
36 |
37 | 54 |
55 |
56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /packages/frontend/src/components/SpaceUsersIndicator.tsx: -------------------------------------------------------------------------------- 1 | import { WebsocketProvider } from "y-websocket"; 2 | 3 | import useYjsSpaceAwarenessStates from "@/hooks/useYjsSpaceAwareness"; 4 | import { useYjsStore } from "@/store/yjs"; 5 | 6 | function SpaceUserAvatar({ color }: { color: string }) { 7 | return ( 8 |
9 |
13 |
14 | ); 15 | } 16 | 17 | export default function SpaceUsersIndicator() { 18 | const { yProvider } = useYjsStore(); 19 | const awareness = (yProvider as WebsocketProvider | undefined)?.awareness; 20 | const { states: userStates } = useYjsSpaceAwarenessStates(awareness); 21 | 22 | return ( 23 |
24 | {userStates.size > 4 && ( 25 | +{userStates.size - 4} 26 | )} 27 |
28 | {[...userStates].slice(0, 4).map(([userId, { color }]) => ( 29 | 30 | ))} 31 |
32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /packages/frontend/src/components/note/Block.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | 3 | import { BlockProvider } from "@milkdown/kit/plugin/block"; 4 | import { useInstance } from "@milkdown/react"; 5 | 6 | export const BlockView = () => { 7 | const ref = useRef(null); 8 | const tooltipProvider = useRef(); 9 | 10 | const [loading, get] = useInstance(); 11 | 12 | useEffect(() => { 13 | const div = ref.current; 14 | if (loading || !div) return undefined; 15 | 16 | const editor = get(); 17 | if (!editor) return undefined; 18 | 19 | tooltipProvider.current = new BlockProvider({ 20 | ctx: editor.ctx, 21 | content: div, 22 | }); 23 | tooltipProvider.current?.update(); 24 | 25 | return () => { 26 | tooltipProvider.current?.destroy(); 27 | }; 28 | }, [loading, get]); 29 | 30 | return ( 31 |
35 | 43 | 50 | 51 |
52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /packages/frontend/src/components/note/Editor.css: -------------------------------------------------------------------------------- 1 | .milkdown { 2 | @apply m-8 px-4 py-6; 3 | } 4 | 5 | .editor { 6 | @apply mx-auto max-w-2xl; 7 | @apply prose; 8 | } 9 | 10 | /* 첫 번째 문단이 비어있을 때 초기 커서 위치에 대한 임시적인 수정사항 */ 11 | .ProseMirror > .ProseMirror-yjs-cursor:first-child { 12 | margin-top: 16px; 13 | } 14 | .ProseMirror p:first-child, 15 | .ProseMirror h1:first-child, 16 | .ProseMirror h2:first-child, 17 | .ProseMirror h3:first-child, 18 | .ProseMirror h4:first-child, 19 | .ProseMirror h5:first-child, 20 | .ProseMirror h6:first-child { 21 | margin-top: 16px; 22 | } 23 | 24 | /* 다른 유저의 커서 (user caret) 25 | The colors are automatically overwritten */ 26 | 27 | .ProseMirror-yjs-cursor { 28 | position: relative; 29 | margin-left: -1px; 30 | margin-right: -1px; 31 | border-left: 1px solid black; 32 | border-right: 1px solid black; 33 | border-color: orange; 34 | word-break: normal; 35 | pointer-events: none; 36 | } 37 | 38 | /* This renders the username above the caret */ 39 | .ProseMirror-yjs-cursor > div { 40 | position: absolute; 41 | top: -1.05em; 42 | left: -1px; 43 | font-size: 13px; 44 | background-color: rgb(250, 129, 0); 45 | font-family: serif; 46 | font-style: normal; 47 | font-weight: normal; 48 | line-height: normal; 49 | user-select: none; 50 | color: white; 51 | padding-left: 2px; 52 | padding-right: 2px; 53 | white-space: nowrap; 54 | } 55 | 56 | .ProseMirror-focused { 57 | outline: none; 58 | } 59 | 60 | .ProseMirror[data-placeholder]::before { 61 | color: #a9a9a9; 62 | position: absolute; 63 | content: attr(data-placeholder); 64 | pointer-events: none; 65 | font-weight: 700; 66 | font-size: 36px; 67 | line-height: 40px; 68 | } 69 | 70 | milkdown-code-block { 71 | display: block; 72 | position: relative; 73 | margin: 20px 0; 74 | 75 | .language-picker { 76 | width: max-content; 77 | position: absolute; 78 | z-index: 1; 79 | display: none; 80 | padding-top: 8px; 81 | } 82 | 83 | .language-picker.show { 84 | display: block; 85 | } 86 | 87 | .language-button { 88 | display: flex; 89 | align-items: center; 90 | } 91 | 92 | .search-box { 93 | display: flex; 94 | align-items: center; 95 | } 96 | 97 | .search-box .clear-icon { 98 | cursor: pointer; 99 | } 100 | 101 | .hidden { 102 | display: none; 103 | } 104 | 105 | .cm-editor { 106 | outline: none !important; 107 | } 108 | 109 | .language-button { 110 | gap: 8px; 111 | padding: 8px; 112 | background: #FAF9F7; 113 | border-radius: 8px; 114 | font-size: 14px; 115 | margin-bottom: 16px; 116 | } 117 | 118 | .language-button:hover { 119 | background: #DED8D3; 120 | } 121 | 122 | .language-button .expand-icon { 123 | transition: transform 0.2s ease-in-out; 124 | } 125 | 126 | .language-button .expand-icon svg { 127 | width: 16px; 128 | height: 16px; 129 | } 130 | 131 | .language-button[data-expanded="true"] .expand-icon { 132 | transform: rotate(180deg); 133 | } 134 | 135 | .language-button .expand-icon svg:focus, 136 | .language-button .expand-icon:focus-visible { 137 | outline: none; 138 | } 139 | 140 | .list-wrapper { 141 | background: #FAF9F7; 142 | border-radius: 16px; 143 | box-shadow: 0px 2px 6px 0px rgba(0, 0, 0, 0.15), 0px 1px 2px 0px rgba(0, 0, 0, 0.30); 144 | width: 220px; 145 | } 146 | 147 | .language-list { 148 | height: 356px; 149 | overflow-y: auto; 150 | margin: 0; 151 | padding: 0; 152 | } 153 | 154 | .language-list-item { 155 | cursor: pointer; 156 | margin: 0; 157 | height: 32px; 158 | display: flex; 159 | align-items: center; 160 | gap: 8px; 161 | padding: 0 8px; 162 | font-size: 12px; 163 | } 164 | 165 | .language-list-item .leading, 166 | .language-list-item .leading svg { 167 | width: 16px; 168 | height: 16px; 169 | } 170 | 171 | .list-wrapper { 172 | padding-top: 20px; 173 | } 174 | 175 | .search-box { 176 | margin: 0 16px 12px; 177 | background: white; 178 | height: 32px; 179 | border-radius: 4px; 180 | outline: 2px solid #E5E0DC; 181 | gap: 8px; 182 | padding: 0 16px; 183 | font-size: 12px; 184 | } 185 | 186 | .search-box .search-input { 187 | width: 100%; 188 | } 189 | 190 | .search-box .search-icon svg { 191 | width: 16px; 192 | height: 16px; 193 | } 194 | 195 | .search-box .clear-icon svg { 196 | width: 16px; 197 | height: 16px; 198 | } 199 | 200 | .search-box input { 201 | background: transparent; 202 | } 203 | 204 | .search-box input:focus { 205 | outline: none; 206 | } 207 | } -------------------------------------------------------------------------------- /packages/frontend/src/components/note/Editor.tsx: -------------------------------------------------------------------------------- 1 | import { useParams } from "react-router-dom"; 2 | 3 | import { Milkdown, MilkdownProvider } from "@milkdown/react"; 4 | import "@milkdown/theme-nord/style.css"; 5 | import { ProsemirrorAdapterProvider } from "@prosemirror-adapter/react"; 6 | 7 | import { WS_URL } from "@/api/constants"; 8 | import useMilkdownCollab from "@/hooks/useMilkdownCollab"; 9 | import useMilkdownEditor from "@/hooks/useMilkdownEditor"; 10 | 11 | import { BlockView } from "./Block"; 12 | import "./Editor.css"; 13 | 14 | function MilkdownEditor() { 15 | const { noteId } = useParams>(); 16 | 17 | const { loading, get } = useMilkdownEditor({ 18 | BlockView, 19 | }); 20 | 21 | useMilkdownCollab({ 22 | editor: loading ? null : get() || null, 23 | websocketUrl: `${WS_URL}/note`, 24 | roomName: noteId || "", 25 | }); 26 | return ; 27 | } 28 | 29 | function MilkdownEditorWrapper() { 30 | return ( 31 | 32 | 33 | 34 | 35 | 36 | ); 37 | } 38 | 39 | export default MilkdownEditorWrapper; 40 | -------------------------------------------------------------------------------- /packages/frontend/src/components/space/GooeyConnection.tsx: -------------------------------------------------------------------------------- 1 | import { Circle, Shape } from "react-konva"; 2 | 3 | import { Vector2d } from "konva/lib/types"; 4 | 5 | import { getDistanceFromPoints } from "@/lib/utils"; 6 | 7 | type GooeyConnectionProps = { 8 | startPosition: Vector2d; 9 | endPosition: Vector2d; 10 | debug?: boolean; 11 | }; 12 | 13 | export default function GooeyConnection({ 14 | startPosition, 15 | endPosition, 16 | debug = false, 17 | }: GooeyConnectionProps) { 18 | const distance = getDistanceFromPoints(startPosition, endPosition); 19 | const NODE_RADIUS = 32; 20 | const HONEY_COLOR = "#FFF2CB"; 21 | 22 | const dx = endPosition.x - startPosition.x; 23 | const dy = endPosition.y - startPosition.y; 24 | const angle = Math.atan2(dy, dx); 25 | 26 | // 거리에 따라 컨트롤 Point 결정 27 | const controlDistance = Math.min(distance * 0.5, NODE_RADIUS * 2); 28 | const middlePoint = { 29 | x: startPosition.x + (Math.cos(angle) * distance) / 2, 30 | y: startPosition.y + (Math.sin(angle) * distance) / 2, 31 | }; 32 | 33 | const tangentLine = Math.sqrt((distance / 2) ** 2 - (2 * NODE_RADIUS) ** 2); 34 | const tangentAngle = Math.atan2(2 * NODE_RADIUS, tangentLine); 35 | 36 | // 제어점 계산 37 | const ctrl1 = { 38 | x: startPosition.x + Math.cos(angle) * controlDistance * 1.3, 39 | y: startPosition.y + Math.sin(angle) * controlDistance * 1.3, 40 | }; 41 | const ctrl2 = { 42 | x: endPosition.x - Math.cos(angle) * controlDistance * 1.3, 43 | y: endPosition.y - Math.sin(angle) * controlDistance * 1.3, 44 | }; 45 | 46 | // 곡선 시작점-끝점 47 | const start1 = { 48 | x: middlePoint.x - Math.cos(angle - tangentAngle) * tangentLine, 49 | y: middlePoint.y - Math.sin(angle - tangentAngle) * tangentLine, 50 | }; 51 | 52 | const start2 = { 53 | x: middlePoint.x - Math.cos(angle + tangentAngle) * tangentLine, 54 | y: middlePoint.y - Math.sin(angle + tangentAngle) * tangentLine, 55 | }; 56 | 57 | const end1 = { 58 | x: middlePoint.x + Math.cos(angle + tangentAngle) * tangentLine, 59 | y: middlePoint.y + Math.sin(angle + tangentAngle) * tangentLine, 60 | }; 61 | 62 | const end2 = { 63 | x: middlePoint.x + Math.cos(angle - tangentAngle) * tangentLine, 64 | y: middlePoint.y + Math.sin(angle - tangentAngle) * tangentLine, 65 | }; 66 | 67 | return ( 68 | <> 69 | { 71 | // 베지어 곡선 그리기 72 | context.beginPath(); 73 | context.moveTo(start1.x, start1.y); 74 | context.bezierCurveTo( 75 | ctrl1.x, 76 | ctrl1.y, 77 | ctrl2.x, 78 | ctrl2.y, 79 | end1.x, 80 | end1.y, 81 | ); 82 | context.lineTo(end2.x, end2.y); 83 | context.bezierCurveTo( 84 | ctrl2.x, 85 | ctrl2.y, 86 | ctrl1.x, 87 | ctrl1.y, 88 | start2.x, 89 | start2.y, 90 | ); 91 | context.closePath(); 92 | 93 | const gradient = context.createLinearGradient( 94 | startPosition.x, 95 | startPosition.y, 96 | endPosition.x, 97 | endPosition.y, 98 | ); 99 | gradient.addColorStop(0, `${HONEY_COLOR}`); 100 | gradient.addColorStop(0.5, `${HONEY_COLOR}80`); 101 | gradient.addColorStop(1, `${HONEY_COLOR}`); 102 | 103 | context.fillStyle = gradient; 104 | context.fill(); 105 | context.strokeStyle = HONEY_COLOR; 106 | context.lineWidth = 2 / (1 + distance / NODE_RADIUS); // 거리에 따라 선 두께 조절 107 | context.stroke(); 108 | }} 109 | /> 110 | {/* 제어점 디버깅 용도 */} 111 | {debug && ( 112 | <> 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | )} 121 | 122 | ); 123 | } 124 | -------------------------------------------------------------------------------- /packages/frontend/src/components/space/GooeyNode.tsx: -------------------------------------------------------------------------------- 1 | import { Circle } from "react-konva"; 2 | 3 | import { Vector2d } from "konva/lib/types"; 4 | 5 | import GooeyConnection from "./GooeyConnection"; 6 | 7 | type GooeyNodeProps = { 8 | startPosition: Vector2d; 9 | dragPosition: Vector2d; 10 | connectionVisible?: boolean; 11 | color?: string; 12 | }; 13 | 14 | export default function GooeyNode({ 15 | startPosition, 16 | dragPosition, 17 | connectionVisible = true, 18 | color = "#FFF2CB", 19 | }: GooeyNodeProps) { 20 | return ( 21 | <> 22 | 23 | {connectionVisible && ( 24 | 28 | )} 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /packages/frontend/src/components/space/InteractionGuide.tsx: -------------------------------------------------------------------------------- 1 | import { InfoIcon } from "lucide-react"; 2 | 3 | import { 4 | HoverCard, 5 | HoverCardContent, 6 | HoverCardTrigger, 7 | } from "@/components/ui/hover-card"; 8 | 9 | const interactions = [ 10 | { 11 | id: "node-drag", 12 | title: "새로운 노드 생성", 13 | description: "노드 드래그", 14 | }, 15 | { 16 | id: "node-move", 17 | title: "노드 이동", 18 | description: "클릭 + 0.5초 이상 홀드", 19 | }, 20 | { 21 | id: "screen-move", 22 | title: "화면 이동", 23 | description: "스페이스 빈 공간 클릭 후 드래그", 24 | }, 25 | { 26 | id: "screen-zoom", 27 | title: "화면 줌", 28 | description: "ctrl + 마우스 휠 또는 트랙패드 제스처", 29 | }, 30 | { 31 | id: "node-edit", 32 | title: "노드 편집", 33 | description: "노드 위에서 우클릭", 34 | }, 35 | { 36 | id: "edge-edit", 37 | title: "간선 편집", 38 | description: "간선 위에서 우클릭", 39 | }, 40 | ]; 41 | 42 | export default function InteractionGuide() { 43 | return ( 44 | 45 | 46 | 47 | 48 | 49 |
50 |

상호작용 가이드 🐝

51 |
    52 | {interactions.map((interaction) => ( 53 |
  • 54 | {interaction.title}:  55 | {interaction.description} 56 |
  • 57 | ))} 58 |
59 |
60 |
61 |
62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /packages/frontend/src/components/space/NearNodeIndicator.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Circle } from "react-konva"; 3 | 4 | import { Node } from "shared/types"; 5 | 6 | type NearNodeIndicatorProps = { 7 | overlapNode: Node; 8 | }; 9 | 10 | function NearNodeIndicator({ overlapNode }: NearNodeIndicatorProps) { 11 | return ( 12 | 19 | ); 20 | } 21 | 22 | function areEqual( 23 | prevProps: NearNodeIndicatorProps, 24 | nextProps: NearNodeIndicatorProps, 25 | ) { 26 | return ( 27 | prevProps.overlapNode.x === nextProps.overlapNode.x && 28 | prevProps.overlapNode.y === nextProps.overlapNode.y 29 | ); 30 | } 31 | 32 | export const MemoizedNearIndicator = React.memo(NearNodeIndicator, areEqual); 33 | -------------------------------------------------------------------------------- /packages/frontend/src/components/space/SpaceNode.stories.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { Layer, Stage } from "react-konva"; 3 | 4 | import type { Meta, StoryObj } from "@storybook/react"; 5 | 6 | import SpaceNode from "./SpaceNode.tsx"; 7 | 8 | export default { 9 | component: SpaceNode, 10 | tags: ["autodocs"], 11 | decorators: [ 12 | (Story, { canvasElement }) => { 13 | // TODO: Konva Node를 위한 decorator 별도로 분리 必 14 | const [size, setSize] = useState(() => ({ 15 | width: Math.max(canvasElement.clientWidth, 256), 16 | height: Math.max(canvasElement.clientHeight, 256), 17 | })); 18 | 19 | const { width, height } = size; 20 | 21 | useEffect(() => { 22 | const observer = new ResizeObserver((entries) => { 23 | entries.forEach((entry) => { 24 | const { width, height } = entry.contentRect; 25 | setSize({ width, height }); 26 | }); 27 | }); 28 | 29 | observer.observe(canvasElement); 30 | return () => observer.unobserve(canvasElement); 31 | }, [canvasElement]); 32 | 33 | return ( 34 | 35 | 36 | 37 | 38 | 39 | ); 40 | }, 41 | ], 42 | } satisfies Meta; 43 | 44 | export const Normal: StoryObj = { 45 | args: { 46 | label: "HelloWorld", 47 | x: 0, 48 | y: 0, 49 | }, 50 | }; 51 | -------------------------------------------------------------------------------- /packages/frontend/src/components/space/SpaceNode.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, useEffect, useRef, useState } from "react"; 2 | import { Circle, Group, Text } from "react-konva"; 3 | 4 | import Konva from "konva"; 5 | 6 | // FIXME: 이런 동작이 많이 필요할 것 같아 별도의 파일로 분리할 것 7 | function TextWithCenteredAnchor(props: Konva.TextConfig) { 8 | const ref = useRef(null); 9 | 10 | const [offsetX, setOffsetX] = useState(undefined); 11 | const [offsetY, setOffsetY] = useState(undefined); 12 | 13 | useEffect(() => { 14 | if (!ref.current || props.offset !== undefined) { 15 | return; 16 | } 17 | 18 | if (props.offsetX === undefined) { 19 | setOffsetX(ref.current.width() / 2); 20 | } 21 | 22 | if (props.offsetY === undefined) { 23 | setOffsetY(ref.current.height() / 2); 24 | } 25 | }, [props]); 26 | 27 | return ; 28 | } 29 | 30 | export interface SpaceNodeProps { 31 | label?: string; 32 | x: number; 33 | y: number; 34 | } 35 | const SpaceNode = forwardRef( 36 | ({ label, x, y }, ref) => { 37 | // TODO: 색상에 대해 정하기, 크기에 대해 정하기 38 | const fillColor = "royalblue"; 39 | const textColor = "white"; 40 | 41 | return ( 42 | 43 | 44 | 45 | 46 | ); 47 | }, 48 | ); 49 | SpaceNode.displayName = "SpaceNode"; 50 | 51 | export default SpaceNode; 52 | -------------------------------------------------------------------------------- /packages/frontend/src/components/space/SpacePageHeader.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | import { BreadcrumbItem } from "shared/types"; 4 | 5 | import { getBreadcrumbOfSpace } from "@/api/space"; 6 | import { prompt } from "@/lib/prompt-dialog"; 7 | 8 | import SpaceBreadcrumb from "../SpaceBreadcrumb"; 9 | import SpaceShareAlertContent from "../SpaceShareAlertContent"; 10 | import SpaceUsersIndicator from "../SpaceUsersIndicator"; 11 | import { Button } from "../ui/button"; 12 | 13 | type SpacePageHeaderProps = { 14 | spaceId: string; 15 | }; 16 | 17 | export default function SpacePageHeader({ spaceId }: SpacePageHeaderProps) { 18 | const [spacePaths, setSpacePaths] = useState(null); 19 | 20 | useEffect(() => { 21 | async function fetchSpacePaths() { 22 | const data = await getBreadcrumbOfSpace(spaceId); 23 | setSpacePaths(data); 24 | } 25 | 26 | fetchSpacePaths(); 27 | }, [spaceId]); 28 | 29 | return ( 30 |
31 |
32 |
33 | {spacePaths && } 34 |
35 |
36 | 37 | 46 |
47 |
48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /packages/frontend/src/components/space/YjsSpaceView.tsx: -------------------------------------------------------------------------------- 1 | import { RefObject } from "react"; 2 | 3 | import useYjsConnection from "@/hooks/yjs/useYjsConnection"; 4 | import { YjsStoreProvider } from "@/store/yjs"; 5 | 6 | import SpaceView from "./SpaceView"; 7 | 8 | type YjsSpaceViewProps = { 9 | spaceId: string; 10 | autofitTo?: Element | RefObject; 11 | }; 12 | 13 | export default function YjsSpaceView({ 14 | spaceId, 15 | autofitTo, 16 | }: YjsSpaceViewProps) { 17 | const { yDoc, yProvider, setYDoc, setYProvider } = useYjsConnection(spaceId); 18 | 19 | return ( 20 | 21 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /packages/frontend/src/components/space/context-menu/CustomContextMenu.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ContextMenuContent, 3 | ContextMenuItem, 4 | } from "@/components/ui/context-menu"; 5 | 6 | import { ContextMenuItemConfig } from "./type"; 7 | 8 | type CustomContextMenuProps = { 9 | items: ContextMenuItemConfig[]; 10 | }; 11 | 12 | export default function CustomContextMenu({ items }: CustomContextMenuProps) { 13 | return ( 14 | 15 | {items.map((item) => ( 16 | {item.label} 17 | ))} 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /packages/frontend/src/components/space/context-menu/SpaceContextMenuWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { deleteSpace, updateSpace } from "@/api/space"; 4 | import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu"; 5 | import { prompt } from "@/lib/prompt-dialog"; 6 | 7 | import CustomContextMenu from "./CustomContextMenu"; 8 | import { 9 | ContextMenuActions, 10 | ContextMenuItemConfig, 11 | SelectionState, 12 | } from "./type"; 13 | 14 | type SpaceContextMenuWrapperProps = { 15 | children: React.ReactNode; 16 | spaceId: string; 17 | selection: SelectionState; 18 | actions: ContextMenuActions; 19 | }; 20 | 21 | export default function SpaceContextMenuWrapper({ 22 | children, 23 | spaceId, 24 | selection, 25 | actions, 26 | }: SpaceContextMenuWrapperProps) { 27 | const { selectedNode, selectedEdge, clearSelection } = selection; 28 | const { onNodeUpdate, onNodeDelete, onEdgeDelete } = actions; 29 | 30 | const getContextMenuItems = (): ContextMenuItemConfig[] => { 31 | if (selectedNode) { 32 | const nodeTypeConfig = { 33 | note: "노트", 34 | subspace: "하위스페이스", 35 | head: "스페이스", 36 | }; 37 | 38 | return [ 39 | { 40 | label: `${nodeTypeConfig[selectedNode.type]}명 편집`, 41 | action: async () => { 42 | const { nodeNewName } = await prompt( 43 | `${nodeTypeConfig[selectedNode.type]}명 편집`, 44 | "수정할 이름을 입력해주세요.", 45 | { 46 | name: "nodeNewName", 47 | label: "이름", 48 | }, 49 | ); 50 | if (!nodeNewName) return; 51 | 52 | if (selectedNode.type === "subspace" && selectedNode.src) { 53 | try { 54 | await updateSpace(selectedNode.src, { 55 | userId: "honeyflow", 56 | spaceName: nodeNewName, 57 | parentContextNodeId: spaceId, 58 | }); 59 | } catch (error) { 60 | console.error("스페이스 편집 실패:", error); 61 | } 62 | } 63 | 64 | onNodeUpdate(selectedNode.id, { name: nodeNewName }); 65 | clearSelection(); 66 | }, 67 | }, 68 | { 69 | label: "제거", 70 | action: async () => { 71 | // 서브스페이스인 경우 스페이스도 함께 삭제 72 | if (selectedNode.type === "subspace" && selectedNode.src) { 73 | try { 74 | await deleteSpace(selectedNode.src); 75 | } catch (error) { 76 | console.error("스페이스 삭제 실패:", error); 77 | } 78 | } 79 | 80 | onNodeDelete(selectedNode.id); 81 | clearSelection(); 82 | }, 83 | }, 84 | ]; 85 | } 86 | 87 | if (selectedEdge) { 88 | return [ 89 | { 90 | label: "제거", 91 | action: () => { 92 | onEdgeDelete(selectedEdge.id); 93 | clearSelection(); 94 | }, 95 | }, 96 | ]; 97 | } 98 | 99 | return []; 100 | }; 101 | 102 | return ( 103 | { 105 | if (!open) { 106 | clearSelection(); 107 | } 108 | }} 109 | > 110 | {children} 111 | {(selectedNode || selectedEdge) && ( 112 | 113 | )} 114 | 115 | ); 116 | } 117 | -------------------------------------------------------------------------------- /packages/frontend/src/components/space/context-menu/type.ts: -------------------------------------------------------------------------------- 1 | import { Node } from "shared/types"; 2 | 3 | export type ContextMenuItemConfig = { 4 | label: string; 5 | action: () => void; 6 | }; 7 | 8 | export type SelectedNodeInfo = { 9 | id: string; 10 | type: Exclude; 11 | src?: string | undefined; 12 | }; 13 | 14 | export type SelectedEdgeInfo = { 15 | id: string; 16 | }; 17 | 18 | export type ContextMenuActions = { 19 | onNodeUpdate: (nodeId: string, patch: Partial) => void; 20 | onNodeDelete: (nodeId: string) => void; 21 | onEdgeDelete: (edgeId: string) => void; 22 | }; 23 | 24 | export type SelectionState = { 25 | selectedNode: SelectedNodeInfo | null; 26 | selectedEdge: SelectedEdgeInfo | null; 27 | clearSelection: () => void; 28 | }; 29 | -------------------------------------------------------------------------------- /packages/frontend/src/components/ui/breadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { ChevronRight, MoreHorizontal } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Breadcrumb = React.forwardRef< 8 | HTMLElement, 9 | React.ComponentPropsWithoutRef<"nav"> & { 10 | separator?: React.ReactNode 11 | } 12 | >(({ ...props }, ref) =>