├── .dockerignore ├── .github ├── ISSUE_TEMPLATE │ ├── 기능-추가-이슈.md │ └── 버그-이슈.md ├── pull_request_template.md └── workflows │ ├── auto-assign-merge.yml │ ├── ci-pipeline.yml │ ├── develop.yml │ └── main.yml ├── .gitignore ├── README.md ├── apps ├── backend │ ├── .eslintrc.js │ ├── .prettierrc │ ├── README.md │ ├── nest-cli.json │ ├── package.json │ ├── src │ │ ├── app.controller.spec.ts │ │ ├── app.controller.ts │ │ ├── app.module.ts │ │ ├── app.service.ts │ │ ├── auth │ │ │ ├── auth.controller.spec.ts │ │ │ ├── auth.controller.ts │ │ │ ├── auth.module.ts │ │ │ ├── auth.service.spec.ts │ │ │ ├── auth.service.ts │ │ │ ├── dtos │ │ │ │ ├── UpdateUser.dto.ts │ │ │ │ ├── messageResponse.dto.ts │ │ │ │ └── signUp.dto.ts │ │ │ ├── guards │ │ │ │ └── jwt-auth.guard.ts │ │ │ ├── strategies │ │ │ │ ├── kakao.strategy.ts │ │ │ │ └── naver.strategy.ts │ │ │ └── token │ │ │ │ ├── token.module.ts │ │ │ │ └── token.service.ts │ │ ├── edge │ │ │ ├── dtos │ │ │ │ ├── createEdge.dto.ts │ │ │ │ ├── findEdgesResponse.dto.ts │ │ │ │ └── messageResponse.dto.ts │ │ │ ├── edge.controller.spec.ts │ │ │ ├── edge.controller.ts │ │ │ ├── edge.entity.ts │ │ │ ├── edge.module.ts │ │ │ ├── edge.repository.ts │ │ │ ├── edge.service.spec.ts │ │ │ └── edge.service.ts │ │ ├── exception │ │ │ ├── access.exception.ts │ │ │ ├── edge.exception.ts │ │ │ ├── invalid.exception.ts │ │ │ ├── login.exception.ts │ │ │ ├── node.exception.ts │ │ │ ├── page.exception.ts │ │ │ ├── role-duplicate.exception.ts │ │ │ ├── upload.exception.ts │ │ │ ├── user.exception.ts │ │ │ ├── workspace-auth.exception.ts │ │ │ └── workspace.exception.ts │ │ ├── filter │ │ │ └── http-exception.filter.ts │ │ ├── main.ts │ │ ├── node │ │ │ ├── dtos │ │ │ │ ├── coordinateResponse.dto.ts │ │ │ │ ├── createNode.dto.ts │ │ │ │ ├── findNodeResponse.dto.ts │ │ │ │ ├── findNodesResponse.dto..ts │ │ │ │ ├── messageResponse.dto.ts │ │ │ │ ├── moveNode.dto.ts │ │ │ │ └── updateNode.dto.ts │ │ │ ├── node.controller.spec.ts │ │ │ ├── node.controller.ts │ │ │ ├── node.entity.ts │ │ │ ├── node.module.ts │ │ │ ├── node.repository.ts │ │ │ ├── node.service.spec.ts │ │ │ └── node.service.ts │ │ ├── page │ │ │ ├── dtos │ │ │ │ ├── createPage.dto.ts │ │ │ │ ├── createPageResponse.dto.ts │ │ │ │ ├── findPageResponse.dto.ts │ │ │ │ ├── findPagesResponse.dto.ts │ │ │ │ ├── messageResponse.dto.ts │ │ │ │ ├── updatePage.dto.ts │ │ │ │ └── updatePartialPage.dto.ts │ │ │ ├── page.controller.spec.ts │ │ │ ├── page.controller.ts │ │ │ ├── page.entity.ts │ │ │ ├── page.module.ts │ │ │ ├── page.repository.ts │ │ │ ├── page.service.spec.ts │ │ │ └── page.service.ts │ │ ├── red-lock │ │ │ └── red-lock.module.ts │ │ ├── redis │ │ │ ├── redis.module.ts │ │ │ └── redis.service.ts │ │ ├── role │ │ │ ├── role.entity.ts │ │ │ ├── role.module.ts │ │ │ ├── role.repository.ts │ │ │ ├── role.service.spec.ts │ │ │ └── role.service.ts │ │ ├── tasks │ │ │ ├── tasks.module.ts │ │ │ └── tasks.service.ts │ │ ├── upload │ │ │ ├── dtos │ │ │ │ └── imageUploadResponse.dto.ts │ │ │ ├── s3-client.provider.ts │ │ │ ├── upload.config.ts │ │ │ ├── upload.controller.ts │ │ │ ├── upload.module.ts │ │ │ ├── upload.service.spec.ts │ │ │ └── upload.service.ts │ │ ├── user │ │ │ ├── user.entity.ts │ │ │ ├── user.module.ts │ │ │ └── user.repository.ts │ │ └── workspace │ │ │ ├── dtos │ │ │ ├── createWorkspace.dto.ts │ │ │ ├── createWorkspaceInviteUrl.dto.ts │ │ │ ├── createWorkspaceResponse.dto.ts │ │ │ ├── getUserWorkspacesResponse.dto.ts │ │ │ ├── getWorkspaceResponse.dto.ts │ │ │ ├── messageResponse.dto.ts │ │ │ └── userWorkspace.dto.ts │ │ │ ├── workspace.controller.spec.ts │ │ │ ├── workspace.controller.ts │ │ │ ├── workspace.entity.ts │ │ │ ├── workspace.module.ts │ │ │ ├── workspace.repository.ts │ │ │ ├── workspace.service.spec.ts │ │ │ └── workspace.service.ts │ ├── test │ │ ├── app.e2e-spec.ts │ │ └── jest-e2e.json │ ├── tsconfig.build.json │ └── tsconfig.json ├── frontend │ ├── .prettierrc │ ├── README.md │ ├── components.json │ ├── eslint.config.js │ ├── index.html │ ├── package.json │ ├── public │ │ ├── fonts │ │ │ ├── Pretendard-Bold.subset.woff2 │ │ │ ├── Pretendard-Medium.subset.woff2 │ │ │ └── Pretendard-SemiBold.subset.woff2 │ │ └── icons │ │ │ ├── kakao.svg │ │ │ ├── logo.png │ │ │ ├── logo.svg │ │ │ ├── naver.svg │ │ │ ├── warning-icon.png │ │ │ └── workspace-logo.svg │ ├── src │ │ ├── app │ │ │ ├── App.tsx │ │ │ ├── main.tsx │ │ │ ├── providers │ │ │ │ ├── TanstackQueryProvider.tsx │ │ │ │ └── TanstackRouterProvider.tsx │ │ │ ├── routeTree.gen.ts │ │ │ └── routes │ │ │ │ ├── __root.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── join │ │ │ │ └── index.tsx │ │ │ │ └── workspace │ │ │ │ └── $workspaceId.tsx │ │ ├── entities │ │ │ ├── node │ │ │ │ ├── index.ts │ │ │ │ ├── model │ │ │ │ │ └── nodeTypes.ts │ │ │ │ └── ui │ │ │ │ │ ├── GroupNode │ │ │ │ │ └── index.tsx │ │ │ │ │ └── NoteNode │ │ │ │ │ └── index.tsx │ │ │ ├── page │ │ │ │ ├── api │ │ │ │ │ └── pageApi.ts │ │ │ │ ├── index.ts │ │ │ │ └── model │ │ │ │ │ ├── pageMutations.ts │ │ │ │ │ ├── pageStore.ts │ │ │ │ │ └── pageTypes.ts │ │ │ └── user │ │ │ │ ├── index.ts │ │ │ │ ├── model │ │ │ │ ├── useSyncedUsers.ts │ │ │ │ ├── useUserConnection.ts │ │ │ │ └── userStore.ts │ │ │ │ └── ui │ │ │ │ └── UserProfile │ │ │ │ └── index.tsx │ │ ├── features │ │ │ ├── auth │ │ │ │ ├── api │ │ │ │ │ └── authApi.ts │ │ │ │ ├── index.ts │ │ │ │ ├── model │ │ │ │ │ ├── authMutations.ts │ │ │ │ │ ├── authQueries.ts │ │ │ │ │ └── useAuth.ts │ │ │ │ └── ui │ │ │ │ │ ├── LoginForm │ │ │ │ │ └── index.tsx │ │ │ │ │ └── Logout │ │ │ │ │ └── index.tsx │ │ │ ├── canvas │ │ │ │ ├── index.ts │ │ │ │ ├── lib │ │ │ │ │ └── updateNodesMap.ts │ │ │ │ ├── model │ │ │ │ │ ├── calculateHandles.ts │ │ │ │ │ ├── getPosition.ts │ │ │ │ │ ├── sortNodes.ts │ │ │ │ │ ├── useCanvas.ts │ │ │ │ │ ├── useCanvasConnection.ts │ │ │ │ │ └── useCollaborativeCursors.ts │ │ │ │ └── ui │ │ │ │ │ ├── Canvas │ │ │ │ │ └── index.tsx │ │ │ │ │ └── CollaborativeCursors │ │ │ │ │ └── index.tsx │ │ │ ├── canvasTools │ │ │ │ ├── index.ts │ │ │ │ └── ui │ │ │ │ │ ├── CursorButton │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── CursorPreview │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── NodeForm │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── NodePanel │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── ProfileForm │ │ │ │ │ └── index.tsx │ │ │ │ │ └── ProfilePanel │ │ │ │ │ └── index.tsx │ │ │ ├── editor │ │ │ │ ├── api │ │ │ │ │ └── uploadApi.ts │ │ │ │ ├── index.ts │ │ │ │ ├── model │ │ │ │ │ ├── editorStore.ts │ │ │ │ │ ├── upload.ts │ │ │ │ │ ├── useEditor.ts │ │ │ │ │ ├── useEditorConnection.ts │ │ │ │ │ └── useEditorTitle.ts │ │ │ │ └── ui │ │ │ │ │ ├── Editor │ │ │ │ │ ├── extensions.ts │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── prosemirror.css │ │ │ │ │ ├── selectors │ │ │ │ │ │ ├── color-selector.tsx │ │ │ │ │ │ ├── link-selector.tsx │ │ │ │ │ │ ├── math-selector.tsx │ │ │ │ │ │ ├── node-selector.tsx │ │ │ │ │ │ └── text-buttons.tsx │ │ │ │ │ ├── slash-commands.tsx │ │ │ │ │ └── ui │ │ │ │ │ │ ├── button.tsx │ │ │ │ │ │ ├── command.tsx │ │ │ │ │ │ ├── dialog.tsx │ │ │ │ │ │ ├── icons │ │ │ │ │ │ ├── crazy-spinner.tsx │ │ │ │ │ │ ├── font-default.tsx │ │ │ │ │ │ ├── font-mono.tsx │ │ │ │ │ │ ├── font-serif.tsx │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ ├── loading-circle.tsx │ │ │ │ │ │ └── magic.tsx │ │ │ │ │ │ ├── menu.tsx │ │ │ │ │ │ ├── popover.tsx │ │ │ │ │ │ ├── scroll-area.tsx │ │ │ │ │ │ └── separator.tsx │ │ │ │ │ ├── EditorActionPanel │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── EditorTitle │ │ │ │ │ └── index.tsx │ │ │ │ │ └── SaveStatus │ │ │ │ │ └── index.tsx │ │ │ ├── pageSidebar │ │ │ │ ├── index.ts │ │ │ │ ├── model │ │ │ │ │ └── useNoteList.ts │ │ │ │ └── ui │ │ │ │ │ ├── LogoBtn │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── NoteList │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── RemoveNoteModal │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── Tools │ │ │ │ │ └── index.tsx │ │ │ │ │ └── WorkspaceNav │ │ │ │ │ └── index.tsx │ │ │ └── workspace │ │ │ │ ├── api │ │ │ │ ├── workspaceApi.ts │ │ │ │ ├── workspaceStatusApi.ts │ │ │ │ └── worskspaceInviteApi.ts │ │ │ │ ├── index.ts │ │ │ │ ├── model │ │ │ │ ├── inviteLinkStore.ts │ │ │ │ ├── useProtectedWorkspace.ts │ │ │ │ ├── useWorkspaceStatus.ts │ │ │ │ ├── workspaceInviteTypes.ts │ │ │ │ ├── workspaceMutations.ts │ │ │ │ ├── workspaceQuries.ts │ │ │ │ └── workspaceTypes.ts │ │ │ │ └── ui │ │ │ │ ├── ShareTool │ │ │ │ ├── ShareButton.tsx │ │ │ │ ├── SharePanel.tsx │ │ │ │ └── index.tsx │ │ │ │ ├── WorkspaceAddButton │ │ │ │ └── index.tsx │ │ │ │ ├── WorkspaceForm │ │ │ │ └── index.tsx │ │ │ │ └── WorkspaceList │ │ │ │ ├── WorkspaceRemoveModal │ │ │ │ └── index.tsx │ │ │ │ └── index.tsx │ │ ├── shared │ │ │ ├── api │ │ │ │ ├── axios.ts │ │ │ │ ├── index.ts │ │ │ │ └── socketProvider.ts │ │ │ ├── index.css │ │ │ ├── lib │ │ │ │ ├── index.ts │ │ │ │ ├── useWorkspace.ts │ │ │ │ └── utils.ts │ │ │ ├── model │ │ │ │ ├── getPopoverPosition.ts │ │ │ │ ├── index.ts │ │ │ │ ├── useConnectionStatus.ts │ │ │ │ ├── useConnectionStore.ts │ │ │ │ ├── usePopover.ts │ │ │ │ ├── useYText.ts │ │ │ │ └── yjs.ts │ │ │ ├── types.d.ts │ │ │ ├── ui │ │ │ │ ├── ActiveUser │ │ │ │ │ └── index.tsx │ │ │ │ ├── BiggerCursor │ │ │ │ │ └── index.tsx │ │ │ │ ├── Button │ │ │ │ │ └── index.tsx │ │ │ │ ├── Cursor │ │ │ │ │ └── index.tsx │ │ │ │ ├── Dialog │ │ │ │ │ └── index.tsx │ │ │ │ ├── Divider │ │ │ │ │ └── index.tsx │ │ │ │ ├── Emoji │ │ │ │ │ └── index.tsx │ │ │ │ ├── FormField │ │ │ │ │ └── index.tsx │ │ │ │ ├── MousePointer │ │ │ │ │ └── index.tsx │ │ │ │ ├── Popover │ │ │ │ │ ├── Content.tsx │ │ │ │ │ ├── Trigger.tsx │ │ │ │ │ └── index.tsx │ │ │ │ ├── ScrollWrapper │ │ │ │ │ └── index.tsx │ │ │ │ ├── SideWrapper │ │ │ │ │ └── index.tsx │ │ │ │ ├── Switch │ │ │ │ │ └── index.tsx │ │ │ │ └── index.ts │ │ │ └── vite-env.d.ts │ │ └── widgets │ │ │ ├── CanvasToolsView │ │ │ ├── index.ts │ │ │ └── ui │ │ │ │ └── index.tsx │ │ │ ├── CanvasView │ │ │ ├── index.ts │ │ │ └── ui │ │ │ │ └── index.tsx │ │ │ ├── EditorView │ │ │ ├── index.ts │ │ │ ├── model │ │ │ │ └── useEditorView.ts │ │ │ └── ui │ │ │ │ └── index.tsx │ │ │ ├── NodeToolsView │ │ │ ├── index.ts │ │ │ └── ui │ │ │ │ └── index.tsx │ │ │ ├── PageSideBarView │ │ │ ├── index.ts │ │ │ └── ui │ │ │ │ └── index.tsx │ │ │ ├── TopNavView │ │ │ ├── index.ts │ │ │ └── ui │ │ │ │ └── index.tsx │ │ │ └── UserInfoView │ │ │ ├── index.ts │ │ │ └── ui │ │ │ └── index.tsx │ ├── tailwind.config.js │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts └── websocket │ ├── .eslintrc.js │ ├── .prettierrc │ ├── nest-cli.json │ ├── package.json │ ├── src │ ├── app.controller.ts │ ├── app.module.ts │ ├── app.service.ts │ ├── main.ts │ ├── red-lock │ │ └── red-lock.module.ts │ ├── redis │ │ ├── redis.module.ts │ │ └── redis.service.ts │ └── yjs │ │ ├── types │ │ ├── edge.entity.ts │ │ ├── node.entity.ts │ │ └── page.entity.ts │ │ ├── yjs.class.ts │ │ ├── yjs.module.ts │ │ ├── yjs.schema.ts │ │ ├── yjs.service.spec.ts │ │ ├── yjs.service.ts │ │ └── yjs.type.ts │ └── tsconfig.json ├── compose.init.yml ├── compose.local.yml ├── compose.prod.yml ├── package.json ├── services ├── backend │ ├── Dockerfile.local │ └── Dockerfile.prod ├── nginx │ ├── Dockerfile.local │ ├── conf.d │ │ └── default.conf │ └── ssl │ │ └── generate-cert.sh └── websocket │ ├── Dockerfile.local │ └── Dockerfile.prod ├── turbo.json └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | 2 | # compiled output 3 | */dist 4 | */node_modules 5 | dist 6 | node_modules 7 | dist-ssr 8 | 9 | # Logs 10 | logs 11 | *.log 12 | npm-debug.log* 13 | pnpm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | lerna-debug.log* 17 | 18 | # OS 19 | .DS_Store 20 | 21 | # Tests 22 | /coverage 23 | /.nyc_output 24 | 25 | # IDEs and editors 26 | /.idea 27 | .idea 28 | .project 29 | .classpath 30 | .c9/ 31 | *.launch 32 | .settings/ 33 | *.sublime-workspace 34 | *.suo 35 | *.ntvs* 36 | *.njsproj 37 | *.sln 38 | *.sw? 39 | 40 | # IDE - VSCode 41 | .vscode/* 42 | !.vscode/settings.json 43 | !.vscode/tasks.json 44 | !.vscode/launch.json 45 | !.vscode/extensions.json 46 | db.sqlite 47 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/기능-추가-이슈.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 기능 추가 이슈 3 | about: 기능 추가 이슈 4 | title: '' 5 | labels: Feature 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 어떤 기능인가요? 11 | 12 | > 추가하려는 기능에 대해 간결하게 설명해주세요 13 | 14 | ## 작업 상세 내용 15 | 16 | - [ ] TODO 17 | - [ ] TODO 18 | - [ ] TODO 19 | 20 | ## 참고할만한 자료(선택) 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/버그-이슈.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 버그 이슈 3 | about: 버그 이슈 4 | title: '' 5 | labels: Bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 어떤 버그인가요? 11 | 12 | > 어떤 버그인지 간결하게 설명해주세요 13 | 14 | ## 어떤 상황에서 발생한 버그인가요? 15 | 16 | > (가능하면) Given-When-Then 형식으로 서술해주세요 17 | 18 | ## 예상 결과 19 | 20 | > 예상했던 정상적인 결과가 어떤 것이었는지 설명해주세요 21 | 22 | ## 참고할만한 자료(선택) 23 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 4 | 5 | ## 🔖 연관된 이슈 6 | 7 | 8 | 9 | - 10 | 11 | ## 📂 작업 내용 12 | 13 | 14 | 15 | - 16 | 17 | ## 📑 참고 자료 & 스크린샷 (선택) 18 | 19 | ## 📢 리뷰 요구사항 (선택) 20 | 21 | 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # env 2 | .env 3 | .env* 4 | *.local 5 | .turbo 6 | *.crt 7 | *.key 8 | !Dockerfile.local 9 | 10 | # compiled output 11 | */dist 12 | */node_modules 13 | dist 14 | node_modules 15 | dist-ssr 16 | 17 | # Logs 18 | logs 19 | *.log 20 | npm-debug.log* 21 | pnpm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | lerna-debug.log* 25 | *.log.* 26 | 27 | # OS 28 | .DS_Store 29 | 30 | # Tests 31 | /coverage 32 | /.nyc_output 33 | 34 | # IDEs and editors 35 | /.idea 36 | .idea 37 | .project 38 | .classpath 39 | .c9/ 40 | *.launch 41 | .settings/ 42 | *.sublime-workspace 43 | *.suo 44 | *.ntvs* 45 | *.njsproj 46 | *.sln 47 | *.sw? 48 | 49 | # IDE - VSCode 50 | .vscode/* 51 | !.vscode/settings.json 52 | !.vscode/tasks.json 53 | !.vscode/launch.json 54 | !.vscode/extensions.json 55 | db.sqlite 56 | 57 | # Secret Keys 58 | *.crt 59 | *.key 60 | *.pem 61 | 62 | # Production 63 | data/* 64 | -------------------------------------------------------------------------------- /apps/backend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /apps/backend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "tabWidth": 2 5 | } 6 | -------------------------------------------------------------------------------- /apps/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 | -------------------------------------------------------------------------------- /apps/backend/src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | 5 | describe('AppController', () => { 6 | let appController: AppController; 7 | 8 | beforeEach(async () => { 9 | const app: TestingModule = await Test.createTestingModule({ 10 | controllers: [AppController], 11 | providers: [AppService], 12 | }).compile(); 13 | 14 | appController = app.get(AppController); 15 | }); 16 | 17 | describe('root', () => { 18 | it('should return "Hello World!"', () => { 19 | expect(appController.getHello()).toBe('Hello World!'); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /apps/backend/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | 4 | @Controller() 5 | export class AppController { 6 | constructor(private readonly appService: AppService) {} 7 | 8 | @Get() 9 | getHello(): string { 10 | return this.appService.getHello(); 11 | } 12 | 13 | @Get('health') 14 | healthCheck() { 15 | return { 16 | status: 'ok', 17 | timestamp: new Date().toISOString(), 18 | }; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /apps/backend/src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class AppService { 5 | getHello(): string { 6 | return 'Hello World!'; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /apps/backend/src/auth/auth.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AuthController } from './auth.controller'; 3 | import { AuthService } from './auth.service'; 4 | import { JwtAuthGuard } from './guards/jwt-auth.guard'; 5 | import { TokenService } from './token/token.service'; 6 | import { User } from '../user/user.entity'; 7 | 8 | describe('AuthController', () => { 9 | let controller: AuthController; 10 | let authService: AuthService; 11 | 12 | beforeEach(async () => { 13 | const module: TestingModule = await Test.createTestingModule({ 14 | controllers: [AuthController], 15 | providers: [ 16 | { 17 | provide: AuthService, 18 | useValue: { 19 | findUser: jest.fn(), 20 | createUser: jest.fn(), 21 | findUserById: jest.fn(), 22 | }, 23 | }, 24 | { 25 | provide: TokenService, 26 | useValue: { 27 | generateAccessToken: jest.fn(() => 'mockedAccessToken'), 28 | generateRefreshToken: jest.fn(() => 'mockedRefreshToken'), 29 | refreshAccessToken: jest.fn(() => 'mockedAccessToken'), 30 | }, 31 | }, 32 | ], 33 | }) 34 | .overrideGuard(JwtAuthGuard) 35 | .useValue({ 36 | canActivate: jest.fn(() => true), 37 | }) 38 | .compile(); 39 | 40 | controller = module.get(AuthController); 41 | authService = module.get(AuthService); 42 | }); 43 | 44 | it('컨트롤러 클래스가 정상적으로 인스턴스화된다.', () => { 45 | expect(controller).toBeDefined(); 46 | }); 47 | 48 | describe('getProfile', () => { 49 | it('JWT 토큰이 유효한 경우 profile을 return한다.', async () => { 50 | const req = { 51 | user: { sub: 1 }, 52 | } as any; 53 | const returnedUser = { id: 1, snowflakeId: 'snowflake-id-1' } as User; 54 | jest.spyOn(authService, 'findUserById').mockResolvedValue(returnedUser); 55 | 56 | const result = await controller.getProfile(req); 57 | expect(result).toEqual({ 58 | message: '인증된 사용자 정보', 59 | snowflakeId: returnedUser.snowflakeId, 60 | }); 61 | }); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /apps/backend/src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { UserRepository } from '../user/user.repository'; 3 | import { UserModule } from '../user/user.module'; 4 | import { AuthService } from './auth.service'; 5 | import { AuthController } from './auth.controller'; 6 | import { NaverStrategy } from './strategies/naver.strategy'; 7 | import { KakaoStrategy } from './strategies/kakao.strategy'; 8 | import { JwtAuthGuard } from './guards/jwt-auth.guard'; 9 | import { TokenModule } from './token/token.module'; 10 | 11 | @Module({ 12 | imports: [UserModule, TokenModule], 13 | controllers: [AuthController], 14 | providers: [ 15 | AuthService, 16 | NaverStrategy, 17 | KakaoStrategy, 18 | UserRepository, 19 | JwtAuthGuard, 20 | ], 21 | }) 22 | export class AuthModule {} 23 | -------------------------------------------------------------------------------- /apps/backend/src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { UserRepository } from '../user/user.repository'; 3 | import { User } from '../user/user.entity'; 4 | import { SignUpDto } from './dtos/signUp.dto'; 5 | import { UpdateUserDto } from './dtos/UpdateUser.dto'; 6 | import { UserNotFoundException } from '../exception/user.exception'; 7 | import { Snowflake } from '@theinternetfolks/snowflake'; 8 | 9 | @Injectable() 10 | export class AuthService { 11 | constructor(private readonly userRepository: UserRepository) {} 12 | 13 | async findUser(dto: SignUpDto): Promise { 14 | const { providerId, provider } = dto; 15 | 16 | const user = await this.userRepository.findOne({ 17 | where: { providerId, provider }, 18 | }); 19 | 20 | return user; 21 | } 22 | 23 | async signUp(dto: SignUpDto): Promise { 24 | const user = this.userRepository.create({ 25 | ...dto, 26 | snowflakeId: Snowflake.generate(), 27 | }); 28 | return this.userRepository.save(user); 29 | } 30 | 31 | async findUserById(id: number): Promise { 32 | return await this.userRepository.findOneBy({ id }); 33 | } 34 | 35 | async updateUser(id: number, dto: UpdateUserDto) { 36 | // 유저를 찾는다. 37 | const findUser = await this.userRepository.findOneBy({ id }); 38 | 39 | // 유저가 없으면 오류 40 | if (!findUser) { 41 | throw new UserNotFoundException(); 42 | } 43 | 44 | // 유저 갱신 45 | const newPage = Object.assign({}, findUser, dto); 46 | await this.userRepository.save(newPage); 47 | } 48 | 49 | async compareStoredRefreshToken( 50 | id: number, 51 | refreshToken: string, 52 | ): Promise { 53 | // 유저를 찾는다. 54 | const user = await this.userRepository.findOneBy({ id }); 55 | 56 | // 유저가 없으면 오류 57 | if (!user) { 58 | throw new UserNotFoundException(); 59 | } 60 | 61 | // DB에 있는 값과 일치하는지 비교한다 62 | return user.refreshToken === refreshToken; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /apps/backend/src/auth/dtos/UpdateUser.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsString, IsOptional } from 'class-validator'; 3 | 4 | export class UpdateUserDto { 5 | @ApiProperty({ 6 | example: '#FF8A8A', 7 | description: '유저의 커서 색상', 8 | }) 9 | @IsString() 10 | @IsOptional() 11 | cursorColor?: string; 12 | 13 | @ApiProperty({ 14 | example: 'nickname', 15 | description: '유저 닉네임', 16 | }) 17 | @IsString() 18 | @IsOptional() 19 | nickname?: string; 20 | } 21 | -------------------------------------------------------------------------------- /apps/backend/src/auth/dtos/messageResponse.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsString } from 'class-validator'; 3 | 4 | export class MessageResponseDto { 5 | @ApiProperty({ 6 | example: 'OO 생성에 성공했습니다.', 7 | description: 'api 요청 결과 메시지', 8 | }) 9 | @IsString() 10 | message: string; 11 | } 12 | -------------------------------------------------------------------------------- /apps/backend/src/auth/dtos/signUp.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsString, IsEmail, IsIn } from 'class-validator'; 3 | 4 | export class SignUpDto { 5 | @IsString() 6 | @ApiProperty({ 7 | example: 'abc1234', 8 | description: '사용자의 카카오/네이버 아이디', 9 | }) 10 | providerId: string; 11 | 12 | @IsString() 13 | @IsIn(['naver', 'kakao'], { 14 | message: 'provider는 naver 또는 kakao 중 하나여야 합니다.', 15 | }) 16 | @ApiProperty({ 17 | example: 'naver', 18 | description: '연동되는 서비스: 네이버/카카오', 19 | }) 20 | provider: string; 21 | 22 | @IsEmail() 23 | @ApiProperty({ 24 | example: 'abc1234@naver.com', 25 | description: '사용자의 이메일 주소(카카오, 네이버 외에도) 가능', 26 | }) 27 | email: string; 28 | } 29 | -------------------------------------------------------------------------------- /apps/backend/src/auth/strategies/kakao.strategy.ts: -------------------------------------------------------------------------------- 1 | // src/auth/strategies/kakao.strategy.ts 2 | import { Injectable } from '@nestjs/common'; 3 | import { PassportStrategy } from '@nestjs/passport'; 4 | import { Profile, Strategy } from 'passport-kakao'; 5 | import { AuthService } from '../auth.service'; 6 | import { SignUpDto } from '../dtos/signUp.dto'; 7 | 8 | @Injectable() 9 | export class KakaoStrategy extends PassportStrategy(Strategy, 'kakao') { 10 | constructor(private authService: AuthService) { 11 | super({ 12 | clientID: process.env.KAKAO_CLIENT_ID, 13 | clientSecret: process.env.KAKAO_CLIENT_SECRET, 14 | callbackURL: process.env.KAKAO_CALLBACK_URL, 15 | }); 16 | } 17 | 18 | async validate(accessToken: string, refreshToken: string, profile: Profile) { 19 | // 카카오 인증 이후 사용자 정보 처리 20 | const createUserDto: SignUpDto = { 21 | providerId: profile.id, 22 | provider: 'kakao', 23 | email: profile._json.kakao_account.email, 24 | }; 25 | 26 | let user = await this.authService.findUser(createUserDto); 27 | if (!user) { 28 | user = await this.authService.signUp(createUserDto); 29 | } 30 | return user; // req.user로 반환 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /apps/backend/src/auth/strategies/naver.strategy.ts: -------------------------------------------------------------------------------- 1 | // src/auth/strategies/naver.strategy.ts 2 | import { Injectable } from '@nestjs/common'; 3 | import { PassportStrategy } from '@nestjs/passport'; 4 | import { Profile, Strategy } from 'passport-naver'; 5 | import { AuthService } from '../auth.service'; 6 | import { SignUpDto } from '../dtos/signUp.dto'; 7 | 8 | @Injectable() 9 | export class NaverStrategy extends PassportStrategy(Strategy, 'naver') { 10 | constructor(private authService: AuthService) { 11 | super({ 12 | clientID: process.env.NAVER_CLIENT_ID, // 환경 변수로 관리 13 | clientSecret: process.env.NAVER_CLIENT_SECRET, 14 | callbackURL: process.env.NAVER_CALLBACK_URL, 15 | }); 16 | } 17 | 18 | async validate(accessToken: string, refreshToken: string, profile: Profile) { 19 | // 네이버 인증 이후 사용자 정보 처리 20 | const createUserDto: SignUpDto = { 21 | providerId: profile.id, 22 | provider: 'naver', 23 | email: profile._json.email, 24 | }; 25 | 26 | let user = await this.authService.findUser(createUserDto); 27 | if (!user) { 28 | user = await this.authService.signUp(createUserDto); 29 | } 30 | return user; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /apps/backend/src/auth/token/token.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { JwtModule } from '@nestjs/jwt'; 3 | import { TokenService } from './token.service'; 4 | import { ConfigModule, ConfigService } from '@nestjs/config'; 5 | import { UserModule } from '../../user/user.module'; 6 | 7 | @Module({ 8 | imports: [ 9 | UserModule, 10 | ConfigModule, // ConfigModule 등록 11 | JwtModule.registerAsync({ 12 | imports: [ConfigModule], // ConfigModule에서 환경 변수 로드 13 | inject: [ConfigService], 14 | useFactory: async (configService: ConfigService) => ({ 15 | secret: configService.get('JWT_SECRET'), // JWT_SECRET 가져오기 16 | }), 17 | }), 18 | ], 19 | providers: [TokenService], 20 | exports: [TokenService, JwtModule], 21 | }) 22 | export class TokenModule {} 23 | -------------------------------------------------------------------------------- /apps/backend/src/edge/dtos/createEdge.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsNumber } from 'class-validator'; 3 | 4 | export class CreateEdgeDto { 5 | @IsNumber() 6 | @ApiProperty({ 7 | example: 1, 8 | description: '출발 노드의 ID', 9 | }) 10 | fromNode: number; 11 | 12 | @IsNumber() 13 | @ApiProperty({ 14 | example: 1, 15 | description: '도착 노드의 ID', 16 | }) 17 | toNode: number; 18 | } 19 | -------------------------------------------------------------------------------- /apps/backend/src/edge/dtos/findEdgesResponse.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsString, IsArray } from 'class-validator'; 3 | import { Edge } from '../edge.entity'; 4 | 5 | export class FindEdgesResponseDto { 6 | @ApiProperty({ 7 | example: 'OO 생성에 성공했습니다.', 8 | description: 'api 요청 결과 메시지', 9 | }) 10 | @IsString() 11 | message: string; 12 | 13 | @ApiProperty({ 14 | example: [ 15 | { 16 | id: 1, 17 | fromNode: 2, 18 | toNode: 7, 19 | }, 20 | ], 21 | description: '모든 Edge 배열', 22 | }) 23 | @IsArray() 24 | edges: Partial[]; 25 | } 26 | -------------------------------------------------------------------------------- /apps/backend/src/edge/dtos/messageResponse.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsString } from 'class-validator'; 3 | 4 | export class MessageResponseDto { 5 | @ApiProperty({ 6 | example: 'OO 생성에 성공했습니다.', 7 | description: 'api 요청 결과 메시지', 8 | }) 9 | @IsString() 10 | message: string; 11 | } 12 | -------------------------------------------------------------------------------- /apps/backend/src/edge/edge.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | Post, 5 | Delete, 6 | Param, 7 | Body, 8 | HttpCode, 9 | HttpStatus, 10 | ParseIntPipe, 11 | } from '@nestjs/common'; 12 | import { EdgeService } from './edge.service'; 13 | import { CreateEdgeDto } from './dtos/createEdge.dto'; 14 | import { ApiOperation, ApiResponse } from '@nestjs/swagger'; 15 | import { MessageResponseDto } from './dtos/messageResponse.dto'; 16 | import { FindEdgesResponseDto } from './dtos/findEdgesResponse.dto'; 17 | 18 | export enum EdgeResponseMessage { 19 | EDGES_RETURNED = '워크스페이스의 모든 엣지를 가져왔습니다.', 20 | EDGE_CREATED = '엣지를 생성했습니다.', 21 | EDGE_DELETED = '엣지를 삭제했습니다.', 22 | } 23 | 24 | @Controller('edge') 25 | export class EdgeController { 26 | constructor(private readonly edgeService: EdgeService) {} 27 | 28 | @ApiResponse({ type: MessageResponseDto }) 29 | @ApiOperation({ summary: '엣지를 생성합니다.' }) 30 | @Post('/') 31 | @HttpCode(HttpStatus.CREATED) 32 | async createEdge(@Body() body: CreateEdgeDto) { 33 | await this.edgeService.createEdge(body); 34 | 35 | return { 36 | message: EdgeResponseMessage.EDGE_CREATED, 37 | }; 38 | } 39 | 40 | @ApiResponse({ type: MessageResponseDto }) 41 | @ApiOperation({ summary: '엣지를 삭제합니다.' }) 42 | @Delete('/:id') 43 | @HttpCode(HttpStatus.OK) 44 | async deleteEdge( 45 | @Param('id', ParseIntPipe) id: number, 46 | ): Promise<{ message: string }> { 47 | await this.edgeService.deleteEdge(id); 48 | 49 | return { 50 | message: EdgeResponseMessage.EDGE_DELETED, 51 | }; 52 | } 53 | 54 | @ApiResponse({ 55 | type: FindEdgesResponseDto, 56 | }) 57 | @ApiOperation({ summary: '특정 워크스페이스의 모든 엣지들을 가져옵니다.' }) 58 | @Get('/workspace/:workspaceId') 59 | @HttpCode(HttpStatus.OK) 60 | async findPagesByWorkspace( 61 | @Param('workspaceId') workspaceId: string, // Snowflake ID 62 | ): Promise { 63 | const edges = await this.edgeService.findEdgesByWorkspace(workspaceId); 64 | 65 | return { 66 | message: EdgeResponseMessage.EDGES_RETURNED, 67 | edges, 68 | }; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /apps/backend/src/edge/edge.entity.ts: -------------------------------------------------------------------------------- 1 | // edge.entity.ts 2 | import { 3 | Entity, 4 | PrimaryGeneratedColumn, 5 | // Column, 6 | ManyToOne, 7 | JoinColumn, 8 | } from 'typeorm'; 9 | import { Node } from '../node/node.entity'; 10 | import { Workspace } from '../workspace/workspace.entity'; 11 | 12 | @Entity() 13 | export class Edge { 14 | @PrimaryGeneratedColumn('increment') 15 | id: number; 16 | 17 | @ManyToOne(() => Node, (node) => node.outgoingEdges, { onDelete: 'CASCADE' }) 18 | @JoinColumn({ name: 'from_node_id' }) 19 | fromNode: Node; 20 | 21 | @ManyToOne(() => Node, (node) => node.incomingEdges, { onDelete: 'CASCADE' }) 22 | @JoinColumn({ name: 'to_node_id' }) 23 | toNode: Node; 24 | 25 | @ManyToOne(() => Workspace, (workspace) => workspace.edges, { 26 | onDelete: 'CASCADE', 27 | }) 28 | @JoinColumn({ name: 'workspace_id' }) 29 | workspace: Workspace; 30 | 31 | // @Column({ nullable: true }) 32 | // type: string; 33 | 34 | // @Column({ nullable: true }) 35 | // color: string; 36 | } 37 | -------------------------------------------------------------------------------- /apps/backend/src/edge/edge.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, forwardRef } from '@nestjs/common'; 2 | import { EdgeService } from './edge.service'; 3 | import { EdgeController } from './edge.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { Edge } from './edge.entity'; 6 | import { EdgeRepository } from './edge.repository'; 7 | import { NodeModule } from '../node/node.module'; 8 | import { WorkspaceModule } from '../workspace/workspace.module'; 9 | 10 | @Module({ 11 | imports: [ 12 | TypeOrmModule.forFeature([Edge]), 13 | forwardRef(() => NodeModule), 14 | WorkspaceModule, 15 | ], 16 | controllers: [EdgeController], 17 | providers: [EdgeService, EdgeRepository], 18 | exports: [EdgeService], 19 | }) 20 | export class EdgeModule {} 21 | -------------------------------------------------------------------------------- /apps/backend/src/edge/edge.repository.ts: -------------------------------------------------------------------------------- 1 | import { DataSource, Repository } from 'typeorm'; 2 | import { Edge } from './edge.entity'; 3 | import { Injectable } from '@nestjs/common'; 4 | import { InjectDataSource } from '@nestjs/typeorm'; 5 | 6 | @Injectable() 7 | export class EdgeRepository extends Repository { 8 | constructor(@InjectDataSource() private dataSource: DataSource) { 9 | super(Edge, dataSource.createEntityManager()); 10 | } 11 | 12 | async findEdgesByWorkspace(workspaceId: number): Promise { 13 | return this.find({ 14 | where: { workspace: { id: workspaceId } }, 15 | relations: ['fromNode', 'toNode'], 16 | }); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /apps/backend/src/exception/access.exception.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus } from '@nestjs/common'; 2 | 3 | export class ForbiddenAccessException extends HttpException { 4 | constructor() { 5 | super('워크스페이스에 접근할 권한이 없습니다.', HttpStatus.FORBIDDEN); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /apps/backend/src/exception/edge.exception.ts: -------------------------------------------------------------------------------- 1 | import { NotFoundException } from '@nestjs/common'; 2 | 3 | export class EdgeNotFoundException extends NotFoundException { 4 | constructor() { 5 | super(`엣지를 찾지 못했습니다.`); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /apps/backend/src/exception/invalid.exception.ts: -------------------------------------------------------------------------------- 1 | import { ForbiddenException } from '@nestjs/common'; 2 | 3 | export class InvalidTokenException extends ForbiddenException { 4 | constructor() { 5 | super(`유효하지 않은 JWT 토큰입니다.`); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /apps/backend/src/exception/login.exception.ts: -------------------------------------------------------------------------------- 1 | import { ForbiddenException } from '@nestjs/common'; 2 | 3 | export class LoginRequiredException extends ForbiddenException { 4 | constructor() { 5 | super(`로그인이 필요한 서비스입니다.`); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /apps/backend/src/exception/node.exception.ts: -------------------------------------------------------------------------------- 1 | import { NotFoundException } from '@nestjs/common'; 2 | 3 | export class NodeNotFoundException extends NotFoundException { 4 | constructor() { 5 | super(`노드를 찾지 못했습니다.`); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /apps/backend/src/exception/page.exception.ts: -------------------------------------------------------------------------------- 1 | import { NotFoundException } from '@nestjs/common'; 2 | 3 | export class PageNotFoundException extends NotFoundException { 4 | constructor() { 5 | super(`페이지를 찾지 못했습니다.`); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /apps/backend/src/exception/role-duplicate.exception.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException } from '@nestjs/common'; 2 | 3 | export class UserAlreadyInWorkspaceException extends BadRequestException { 4 | constructor() { 5 | super('사용자가 이미 워크스페이스에 등록되어 있습니다.'); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /apps/backend/src/exception/upload.exception.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException } from '@nestjs/common'; 2 | 3 | export class InvalidFileException extends BadRequestException { 4 | constructor() { 5 | super(`유효하지 않은 파일입니다.`); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /apps/backend/src/exception/user.exception.ts: -------------------------------------------------------------------------------- 1 | import { NotFoundException } from '@nestjs/common'; 2 | 3 | export class UserNotFoundException extends NotFoundException { 4 | constructor() { 5 | super(`사용자를 찾지 못했습니다.`); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /apps/backend/src/exception/workspace-auth.exception.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus } from '@nestjs/common'; 2 | 3 | export class NotWorkspaceOwnerException extends HttpException { 4 | constructor() { 5 | super('해당 워크스페이스의 소유자가 아닙니다.', HttpStatus.FORBIDDEN); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /apps/backend/src/exception/workspace.exception.ts: -------------------------------------------------------------------------------- 1 | import { NotFoundException } from '@nestjs/common'; 2 | 3 | export class WorkspaceNotFoundException extends NotFoundException { 4 | constructor() { 5 | super(`워크스페이스를 찾지 못했습니다.`); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /apps/backend/src/filter/http-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExceptionFilter, 3 | Catch, 4 | ArgumentsHost, 5 | HttpException, 6 | } from '@nestjs/common'; 7 | import { Request, Response } from 'express'; 8 | import { Logger } from '@nestjs/common'; 9 | 10 | @Catch(HttpException) 11 | export class HttpExceptionFilter implements ExceptionFilter { 12 | private readonly logger = new Logger(); 13 | 14 | catch(exception: HttpException, host: ArgumentsHost) { 15 | const ctx = host.switchToHttp(); 16 | const response = ctx.getResponse(); 17 | const request = ctx.getRequest(); 18 | const status = exception.getStatus(); 19 | 20 | const log = { 21 | statusCode: status, 22 | timestamp: new Date().toISOString(), 23 | path: request.url, 24 | }; 25 | 26 | this.logger.error(log); 27 | 28 | response.status(status).json(log); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /apps/backend/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import { HttpExceptionFilter } from './filter/http-exception.filter'; 4 | import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; 5 | import { IoAdapter } from '@nestjs/platform-socket.io'; 6 | import * as express from 'express'; 7 | import * as cookieParser from 'cookie-parser'; 8 | 9 | import * as dotenv from 'dotenv'; 10 | dotenv.config(); 11 | 12 | async function bootstrap() { 13 | const app = await NestFactory.create(AppModule); 14 | 15 | app.useWebSocketAdapter(new IoAdapter(app)); 16 | app.useGlobalFilters(new HttpExceptionFilter()); 17 | app.setGlobalPrefix('api'); 18 | app.use(express.urlencoded({ extended: true })); 19 | 20 | // Swagger 설정 (production 환경에서는 비활성화) 21 | if (process.env.NODE_ENV !== 'production') { 22 | const config = new DocumentBuilder() 23 | .setTitle('OctoDocs') 24 | .setDescription('OctoDocs API 명세서') 25 | .build(); 26 | 27 | const documentFactory = () => SwaggerModule.createDocument(app, config); 28 | SwaggerModule.setup('api', app, documentFactory); 29 | } 30 | 31 | app.enableCors({ 32 | origin: 33 | process.env.NODE_ENV === 'production' 34 | ? ['https://octodocs.site', 'https://www.octodocs.site'] 35 | : process.env.origin, 36 | credentials: true, 37 | }); 38 | app.use(cookieParser()); 39 | await app.listen(3000); 40 | } 41 | bootstrap(); 42 | -------------------------------------------------------------------------------- /apps/backend/src/node/dtos/coordinateResponse.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsString, IsNumber, IsObject } from 'class-validator'; 3 | class Coordinate { 4 | @IsNumber() 5 | x: number; 6 | @IsNumber() 7 | y: number; 8 | } 9 | export class CoordinateResponseDto { 10 | @ApiProperty({ 11 | example: 'OO 생성에 성공했습니다.', 12 | description: 'api 요청 결과 메시지', 13 | }) 14 | @IsString() 15 | message: string; 16 | 17 | @ApiProperty({ 18 | example: { 19 | x: 14, 20 | y: 14, 21 | }, 22 | description: 'api 요청 결과 메시지', 23 | }) 24 | @IsObject() 25 | coordinate: Coordinate; 26 | } 27 | -------------------------------------------------------------------------------- /apps/backend/src/node/dtos/createNode.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsString, IsNumber } from 'class-validator'; 3 | 4 | export class CreateNodeDto { 5 | @ApiProperty({ 6 | example: '노드 제목', 7 | description: '노드 제목', 8 | }) 9 | @IsString() 10 | title: string; 11 | 12 | @ApiProperty({ 13 | example: '14', 14 | description: 'x 좌표입니다.', 15 | }) 16 | @IsNumber() 17 | x: number; 18 | 19 | @ApiProperty({ 20 | example: '14', 21 | description: 'y 좌표입니다.', 22 | }) 23 | @IsNumber() 24 | y: number; 25 | } 26 | -------------------------------------------------------------------------------- /apps/backend/src/node/dtos/findNodeResponse.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsObject } from 'class-validator'; 2 | import { Node } from '../node.entity'; 3 | 4 | export class FindNodeResponseDto { 5 | @IsString() 6 | message: string; 7 | 8 | @IsObject() 9 | node: Node; 10 | } 11 | -------------------------------------------------------------------------------- /apps/backend/src/node/dtos/findNodesResponse.dto..ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsArray } from 'class-validator'; 2 | import { Node } from '../node.entity'; 3 | 4 | export class FindNodesResponseDto { 5 | @IsString() 6 | message: string; 7 | 8 | @IsArray() 9 | nodes: Node[]; 10 | } 11 | -------------------------------------------------------------------------------- /apps/backend/src/node/dtos/messageResponse.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsString } from 'class-validator'; 3 | 4 | export class MessageResponseDto { 5 | @ApiProperty({ 6 | example: 'OO 생성에 성공했습니다.', 7 | description: 'api 요청 결과 메시지', 8 | }) 9 | @IsString() 10 | message: string; 11 | } 12 | -------------------------------------------------------------------------------- /apps/backend/src/node/dtos/moveNode.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber } from 'class-validator'; 2 | 3 | export class MoveNodeDto { 4 | @IsNumber() 5 | x: number; 6 | 7 | @IsNumber() 8 | y: number; 9 | } 10 | -------------------------------------------------------------------------------- /apps/backend/src/node/dtos/updateNode.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsNumber } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | export class UpdateNodeDto { 5 | @ApiProperty({ 6 | example: '노드 제목', 7 | description: '노드 제목', 8 | }) 9 | @IsString() 10 | title: string; 11 | 12 | @ApiProperty({ 13 | example: '14', 14 | description: 'x 좌표입니다.', 15 | }) 16 | @IsNumber() 17 | x: number; 18 | 19 | @ApiProperty({ 20 | example: '14', 21 | description: 'y 좌표입니다.', 22 | }) 23 | @IsNumber() 24 | y: number; 25 | } 26 | -------------------------------------------------------------------------------- /apps/backend/src/node/node.entity.ts: -------------------------------------------------------------------------------- 1 | // node.entity.ts 2 | import { 3 | Entity, 4 | PrimaryGeneratedColumn, 5 | Column, 6 | OneToOne, 7 | ManyToOne, 8 | OneToMany, 9 | JoinColumn, 10 | } from 'typeorm'; 11 | import { Page } from '../page/page.entity'; 12 | import { Edge } from '../edge/edge.entity'; 13 | import { Workspace } from '../workspace/workspace.entity'; 14 | 15 | @Entity() 16 | export class Node { 17 | @PrimaryGeneratedColumn('increment') 18 | id: number; 19 | 20 | @Column('float') 21 | x: number; 22 | 23 | @Column('float') 24 | y: number; 25 | 26 | @Column({ default: '#FFFFFF' }) 27 | color: string; 28 | 29 | @OneToOne(() => Page, (page) => page.node, { 30 | cascade: true, 31 | onDelete: 'CASCADE', 32 | }) 33 | @JoinColumn() 34 | page: Page; 35 | 36 | @OneToMany(() => Edge, (edge) => edge.fromNode) 37 | outgoingEdges: Edge[]; 38 | 39 | @OneToMany(() => Edge, (edge) => edge.toNode) 40 | incomingEdges: Edge[]; 41 | 42 | @ManyToOne(() => Workspace, (workspace) => workspace.nodes, { 43 | onDelete: 'CASCADE', 44 | }) 45 | @JoinColumn({ name: 'workspace_id' }) 46 | workspace: Workspace; 47 | } 48 | -------------------------------------------------------------------------------- /apps/backend/src/node/node.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, forwardRef } from '@nestjs/common'; 2 | import { NodeService } from './node.service'; 3 | import { NodeController } from './node.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { Node } from './node.entity'; 6 | import { NodeRepository } from './node.repository'; 7 | import { PageModule } from '../page/page.module'; 8 | import { WorkspaceModule } from '../workspace/workspace.module'; 9 | 10 | @Module({ 11 | imports: [ 12 | TypeOrmModule.forFeature([Node]), 13 | forwardRef(() => PageModule), 14 | WorkspaceModule, 15 | ], 16 | controllers: [NodeController], 17 | providers: [NodeService, NodeRepository], 18 | exports: [NodeService, NodeRepository], 19 | }) 20 | export class NodeModule {} 21 | -------------------------------------------------------------------------------- /apps/backend/src/node/node.repository.ts: -------------------------------------------------------------------------------- 1 | import { DataSource, Repository } from 'typeorm'; 2 | import { Node } from './node.entity'; 3 | import { Injectable } from '@nestjs/common'; 4 | import { InjectDataSource } from '@nestjs/typeorm'; 5 | 6 | @Injectable() 7 | export class NodeRepository extends Repository { 8 | constructor(@InjectDataSource() private dataSource: DataSource) { 9 | super(Node, dataSource.createEntityManager()); 10 | } 11 | 12 | async findById(id: number): Promise { 13 | return await this.findOneBy({ id }); 14 | } 15 | 16 | async findNodesByWorkspace(workspaceId: number): Promise { 17 | return this.find({ 18 | where: { workspace: { id: workspaceId } }, 19 | relations: ['page'], 20 | }); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /apps/backend/src/page/dtos/createPage.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { 3 | IsString, 4 | IsNumber, 5 | IsJSON, 6 | IsOptional, 7 | IsNotEmpty, 8 | } from 'class-validator'; 9 | 10 | export class CreatePageDto { 11 | @ApiProperty({ 12 | example: 'nest.js 사용법', 13 | description: '페이지 제목입니다.', 14 | }) 15 | @IsString() 16 | title: string; 17 | 18 | @ApiProperty({ 19 | example: 'nest를 설치합니다.', 20 | description: '페이지 내용입니다.', 21 | }) 22 | @IsJSON() 23 | content: JSON; 24 | 25 | @ApiProperty({ 26 | example: 'snowflake-id-example', 27 | description: '페이지가 만들어지는 워크스페이스의 (외부) id입니다.', 28 | }) 29 | @IsString() 30 | @IsNotEmpty() 31 | workspaceId: string; // Snowflake ID 32 | 33 | @ApiProperty({ 34 | example: '14', 35 | description: 'x 좌표입니다.', 36 | }) 37 | @IsNumber() 38 | x: number; 39 | 40 | @ApiProperty({ 41 | example: '14', 42 | description: 'y 좌표입니다.', 43 | }) 44 | @IsNumber() 45 | y: number; 46 | 47 | @ApiProperty({ 48 | example: '📝', 49 | description: '페이지 이모지', 50 | required: false, 51 | }) 52 | @IsString() 53 | @IsOptional() 54 | emoji?: string; 55 | } 56 | -------------------------------------------------------------------------------- /apps/backend/src/page/dtos/createPageResponse.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsInt, IsString } from 'class-validator'; 3 | 4 | export class CreatePageResponseDto { 5 | @ApiProperty({ 6 | example: 'OO 생성에 성공했습니다.', 7 | description: 'api 요청 결과 메시지', 8 | }) 9 | @IsString() 10 | message: string; 11 | 12 | @ApiProperty({ 13 | example: 1, 14 | description: '페이지의 PK', 15 | }) 16 | @IsInt() 17 | pageId: number; 18 | } 19 | -------------------------------------------------------------------------------- /apps/backend/src/page/dtos/findPageResponse.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsString, IsObject } from 'class-validator'; 3 | import { Page } from '../page.entity'; 4 | 5 | export class FindPageResponseDto { 6 | @ApiProperty({ 7 | example: 'OO 생성에 성공했습니다.', 8 | description: 'api 요청 결과 메시지', 9 | }) 10 | @IsString() 11 | message: string; 12 | 13 | @ApiProperty({ 14 | example: { 15 | type: 'doc', 16 | content: {}, 17 | }, 18 | description: '모든 Page 배열', 19 | }) 20 | @IsObject() 21 | page: Partial; 22 | } 23 | -------------------------------------------------------------------------------- /apps/backend/src/page/dtos/findPagesResponse.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsString, IsArray } from 'class-validator'; 3 | import { Page } from '../page.entity'; 4 | 5 | export class FindPagesResponseDto { 6 | @ApiProperty({ 7 | example: 'OO 생성에 성공했습니다.', 8 | description: 'api 요청 결과 메시지', 9 | }) 10 | @IsString() 11 | message: string; 12 | 13 | @ApiProperty({ 14 | example: [ 15 | { 16 | id: 1, 17 | title: '페이지 제목', 18 | content: { 19 | type: 'doc', 20 | content: {}, 21 | }, 22 | }, 23 | ], 24 | description: '모든 Page 배열', 25 | }) 26 | @IsArray() 27 | pages: Partial[]; 28 | } 29 | -------------------------------------------------------------------------------- /apps/backend/src/page/dtos/messageResponse.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsString } from 'class-validator'; 3 | 4 | export class MessageResponseDto { 5 | @ApiProperty({ 6 | example: 'OO 생성에 성공했습니다.', 7 | description: 'api 요청 결과 메시지', 8 | }) 9 | @IsString() 10 | message: string; 11 | } 12 | -------------------------------------------------------------------------------- /apps/backend/src/page/dtos/updatePage.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsJSON, IsOptional } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | export class UpdatePageDto { 5 | @ApiProperty({ 6 | example: '페이지 제목입니다.', 7 | description: '페이지 제목.', 8 | }) 9 | @IsString() 10 | @IsOptional() 11 | title?: string; 12 | 13 | @ApiProperty({ 14 | example: "{'doc' : 'type'}", 15 | description: '페이지 내용 JSON 형태', 16 | }) 17 | @IsJSON() 18 | @IsOptional() 19 | content?: JSON; 20 | 21 | @ApiProperty({ 22 | example: '📝', 23 | description: '페이지 이모지', 24 | required: false, 25 | }) 26 | @IsString() 27 | @IsOptional() 28 | emoji?: string; 29 | } 30 | -------------------------------------------------------------------------------- /apps/backend/src/page/dtos/updatePartialPage.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsJSON, IsOptional, IsNumber } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | export class UpdatePartialPageDto { 5 | @ApiProperty({ 6 | example: 1, 7 | description: 'page PK', 8 | }) 9 | @IsNumber() 10 | id: number; 11 | 12 | @ApiProperty({ 13 | example: '페이지 제목입니다.', 14 | description: '페이지 제목.', 15 | }) 16 | @IsString() 17 | @IsOptional() 18 | title?: string; 19 | 20 | @ApiProperty({ 21 | example: "{'doc' : 'type'}", 22 | description: '페이지 내용 JSON 형태', 23 | }) 24 | @IsJSON() 25 | @IsOptional() 26 | content?: JSON; 27 | 28 | @ApiProperty({ 29 | example: '📝', 30 | description: '페이지 이모지', 31 | required: false, 32 | }) 33 | @IsString() 34 | @IsOptional() 35 | emoji?: string; 36 | } 37 | -------------------------------------------------------------------------------- /apps/backend/src/page/page.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | Entity, 4 | OneToOne, 5 | ManyToOne, 6 | PrimaryGeneratedColumn, 7 | JoinColumn, 8 | CreateDateColumn, 9 | UpdateDateColumn, 10 | VersionColumn, 11 | } from 'typeorm'; 12 | import { Node } from '../node/node.entity'; 13 | import { Workspace } from '../workspace/workspace.entity'; 14 | 15 | @Entity() 16 | export class Page { 17 | @PrimaryGeneratedColumn('increment') 18 | id: number; 19 | 20 | @Column({ nullable: true }) 21 | title: string; 22 | 23 | @Column('json', { nullable: true }) //TODO: Postgres에서는 jsonb로 변경 24 | content: JSON; 25 | 26 | @CreateDateColumn() 27 | createdAt: Date; 28 | 29 | @UpdateDateColumn() 30 | updatedAt: Date; 31 | 32 | @VersionColumn() 33 | version: number; 34 | 35 | @Column({ nullable: true }) 36 | emoji: string | null; 37 | 38 | @OneToOne(() => Node, (node) => node.page, { 39 | onDelete: 'CASCADE', 40 | }) 41 | @JoinColumn() 42 | node: Node; 43 | 44 | @ManyToOne(() => Workspace, (workspace) => workspace.pages, { 45 | onDelete: 'CASCADE', 46 | }) 47 | @JoinColumn({ name: 'workspace_id' }) 48 | workspace: Workspace; 49 | } 50 | -------------------------------------------------------------------------------- /apps/backend/src/page/page.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, forwardRef } from '@nestjs/common'; 2 | import { PageService } from './page.service'; 3 | import { PageController } from './page.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { Page } from './page.entity'; 6 | import { PageRepository } from './page.repository'; 7 | import { NodeModule } from '../node/node.module'; 8 | import { WorkspaceModule } from '../workspace/workspace.module'; 9 | import { RedLockModule } from '../red-lock/red-lock.module'; 10 | 11 | @Module({ 12 | imports: [ 13 | TypeOrmModule.forFeature([Page]), 14 | forwardRef(() => NodeModule), 15 | WorkspaceModule, 16 | RedLockModule, 17 | ], 18 | controllers: [PageController], 19 | providers: [PageService, PageRepository], 20 | exports: [PageService, PageRepository], 21 | }) 22 | export class PageModule {} 23 | -------------------------------------------------------------------------------- /apps/backend/src/page/page.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { DataSource, Repository } from 'typeorm'; 3 | import { Page } from './page.entity'; 4 | import { InjectDataSource } from '@nestjs/typeorm'; 5 | import { UpdatePartialPageDto } from './dtos/updatePartialPage.dto'; 6 | 7 | @Injectable() 8 | export class PageRepository extends Repository { 9 | constructor(@InjectDataSource() private dataSource: DataSource) { 10 | super(Page, dataSource.createEntityManager()); 11 | } 12 | 13 | async findPagesByWorkspace(workspaceId: number): Promise[]> { 14 | return this.find({ 15 | where: { workspace: { id: workspaceId } }, 16 | select: { 17 | id: true, 18 | title: true, 19 | emoji: true, 20 | }, 21 | }); 22 | } 23 | 24 | async bulkUpdate(pages: UpdatePartialPageDto[]) { 25 | await Promise.all(pages.map((page) => this.update(page.id, page))); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /apps/backend/src/red-lock/red-lock.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, forwardRef } from '@nestjs/common'; 2 | import Redis from 'ioredis'; 3 | import Redlock from 'redlock'; 4 | import { RedisModule } from '../redis/redis.module'; 5 | const RED_LOCK_TOKEN = 'RED_LOCK'; 6 | const REDIS_CLIENT_TOKEN = 'REDIS_CLIENT'; 7 | 8 | @Module({ 9 | imports: [forwardRef(() => RedisModule)], 10 | providers: [ 11 | { 12 | provide: RED_LOCK_TOKEN, 13 | useFactory: (redisClient: Redis) => { 14 | return new Redlock([redisClient], { 15 | driftFactor: 0.01, 16 | retryCount: 10, 17 | retryDelay: 200, 18 | retryJitter: 200, 19 | automaticExtensionThreshold: 500, 20 | }); 21 | }, 22 | inject: [REDIS_CLIENT_TOKEN], 23 | }, 24 | ], 25 | exports: [RED_LOCK_TOKEN], 26 | }) 27 | export class RedLockModule {} 28 | -------------------------------------------------------------------------------- /apps/backend/src/redis/redis.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, forwardRef } from '@nestjs/common'; 2 | import { ConfigModule, ConfigService } from '@nestjs/config'; 3 | import { RedisService } from './redis.service'; 4 | import Redis from 'ioredis'; 5 | import { RedLockModule } from '../red-lock/red-lock.module'; 6 | 7 | // 의존성 주입할 때 redis client를 식별할 토큰 8 | const REDIS_CLIENT_TOKEN = 'REDIS_CLIENT'; 9 | 10 | @Module({ 11 | imports: [ConfigModule, forwardRef(() => RedLockModule)], // ConfigModule 추가 12 | providers: [ 13 | RedisService, 14 | { 15 | provide: REDIS_CLIENT_TOKEN, 16 | inject: [ConfigService], // ConfigService 주입 17 | useFactory: (configService: ConfigService) => { 18 | return new Redis({ 19 | host: configService.get('REDIS_HOST'), 20 | port: configService.get('REDIS_PORT'), 21 | }); 22 | }, 23 | }, 24 | ], 25 | exports: [RedisService, REDIS_CLIENT_TOKEN], 26 | }) 27 | export class RedisModule {} 28 | -------------------------------------------------------------------------------- /apps/backend/src/redis/redis.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Inject } from '@nestjs/common'; 3 | import Redis from 'ioredis'; 4 | import Redlock from 'redlock'; 5 | const REDIS_CLIENT_TOKEN = 'REDIS_CLIENT'; 6 | const RED_LOCK_TOKEN = 'RED_LOCK'; 7 | 8 | export type RedisPage = { 9 | title?: string; 10 | content?: string; 11 | emoji?: string; 12 | }; 13 | 14 | export type RedisNode = { 15 | x?: number; 16 | y?: number; 17 | color?: string; 18 | }; 19 | 20 | export type RedisEdge = { 21 | fromNode: number; 22 | toNode: number; 23 | type: 'add' | 'delete'; 24 | }; 25 | 26 | @Injectable() 27 | export class RedisService { 28 | constructor( 29 | @Inject(REDIS_CLIENT_TOKEN) private readonly redisClient: Redis, 30 | @Inject(RED_LOCK_TOKEN) private readonly redisLock: Redlock, 31 | ) {} 32 | 33 | async getAllKeys(pattern) { 34 | return await this.redisClient.keys(pattern); 35 | } 36 | 37 | createStream() { 38 | return this.redisClient.scanStream(); 39 | } 40 | 41 | async get(key: string) { 42 | const data = await this.redisClient.hgetall(key); 43 | return Object.fromEntries( 44 | Object.entries(data).map(([field, value]) => [field, value]), 45 | ); 46 | } 47 | 48 | async set(key: string, value: object) { 49 | // 락을 획득할 때까지 기다린다. 50 | const lock = await this.redisLock.acquire([`user:${key}`], 1000); 51 | try { 52 | await this.redisClient.hset(key, Object.entries(value)); 53 | } finally { 54 | lock.release(); 55 | } 56 | } 57 | 58 | async setField(key: string, field: string, value: string) { 59 | // 락을 획득할 때까지 기다린다. 60 | const lock = await this.redisLock.acquire([`user:${key}`], 1000); 61 | try { 62 | return await this.redisClient.hset(key, field, value); 63 | } finally { 64 | lock.release(); 65 | } 66 | } 67 | 68 | async delete(key: string) { 69 | // 락을 획득할 때까지 기다린다. 70 | const lock = await this.redisLock.acquire([`user:${key}`], 1000); 71 | try { 72 | return await this.redisClient.del(key); 73 | } finally { 74 | lock.release(); 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /apps/backend/src/role/role.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | Entity, 4 | ManyToOne, 5 | PrimaryColumn, 6 | CreateDateColumn, 7 | } from 'typeorm'; 8 | import { User } from '../user/user.entity'; 9 | import { Workspace } from '../workspace/workspace.entity'; 10 | 11 | @Entity() 12 | export class Role { 13 | @PrimaryColumn() 14 | workspaceId: number; 15 | 16 | @PrimaryColumn() 17 | userId: number; 18 | 19 | @ManyToOne(() => Workspace, (workspace) => workspace.id, { 20 | onDelete: 'CASCADE', 21 | }) 22 | workspace: Workspace; 23 | 24 | @ManyToOne(() => User, (user) => user.id, { onDelete: 'CASCADE' }) 25 | user: User; 26 | 27 | // 'owner' 또는 'guest' 28 | // 저 중 하나로 제한 필요함 -> service에서 관리해야됨 29 | @Column() 30 | role: string; 31 | 32 | @CreateDateColumn() 33 | createdAt: Date; 34 | } 35 | -------------------------------------------------------------------------------- /apps/backend/src/role/role.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, forwardRef } from '@nestjs/common'; 2 | import { RoleService } from './role.service'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { Role } from './role.entity'; 5 | import { RoleRepository } from './role.repository'; 6 | import { UserModule } from '../user/user.module'; 7 | import { WorkspaceModule } from '../workspace/workspace.module'; 8 | 9 | @Module({ 10 | imports: [ 11 | TypeOrmModule.forFeature([Role]), 12 | forwardRef(() => UserModule), 13 | forwardRef(() => WorkspaceModule), 14 | ], 15 | providers: [RoleService, RoleRepository], 16 | exports: [RoleService, RoleRepository], 17 | }) 18 | export class RoleModule {} 19 | -------------------------------------------------------------------------------- /apps/backend/src/role/role.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { DataSource, Repository } from 'typeorm'; 3 | import { Role } from './role.entity'; 4 | import { InjectDataSource } from '@nestjs/typeorm'; 5 | 6 | @Injectable() 7 | export class RoleRepository extends Repository { 8 | constructor(@InjectDataSource() private dataSource: DataSource) { 9 | super(Role, dataSource.createEntityManager()); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /apps/backend/src/role/role.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { RoleService } from './role.service'; 3 | import { RoleRepository } from './role.repository'; 4 | import { UserRepository } from '../user/user.repository'; 5 | import { WorkspaceRepository } from '../workspace/workspace.repository'; 6 | 7 | describe('RoleService', () => { 8 | let service: RoleService; 9 | let userRepository: UserRepository; 10 | let workspaceRepository: WorkspaceRepository; 11 | let roleRepository: RoleRepository; 12 | 13 | beforeEach(async () => { 14 | const module: TestingModule = await Test.createTestingModule({ 15 | providers: [ 16 | RoleService, 17 | { 18 | provide: RoleRepository, 19 | useValue: {}, 20 | }, 21 | { 22 | provide: UserRepository, 23 | useValue: {}, 24 | }, 25 | { 26 | provide: WorkspaceRepository, 27 | useValue: {}, 28 | }, 29 | ], 30 | }).compile(); 31 | 32 | service = module.get(RoleService); 33 | roleRepository = module.get(RoleRepository); 34 | userRepository = module.get(UserRepository); 35 | workspaceRepository = module.get(WorkspaceRepository); 36 | }); 37 | 38 | it('should be defined', () => { 39 | expect(service).toBeDefined(); 40 | expect(userRepository).toBeDefined(); 41 | expect(roleRepository).toBeDefined(); 42 | expect(workspaceRepository).toBeDefined(); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /apps/backend/src/role/role.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { RoleRepository } from './role.repository'; 3 | import { UserRepository } from '../user/user.repository'; 4 | import { WorkspaceRepository } from '../workspace/workspace.repository'; 5 | 6 | @Injectable() 7 | export class RoleService { 8 | constructor( 9 | private readonly roleRepository: RoleRepository, 10 | private readonly workspaceRepository: WorkspaceRepository, 11 | private readonly userRepository: UserRepository, 12 | ) {} 13 | } 14 | -------------------------------------------------------------------------------- /apps/backend/src/tasks/tasks.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TasksService } from './tasks.service'; 3 | import { RedisModule } from '../redis/redis.module'; 4 | import { PageModule } from '../page/page.module'; 5 | 6 | @Module({ 7 | imports: [RedisModule, PageModule], 8 | providers: [TasksService], 9 | }) 10 | export class TasksModule {} 11 | -------------------------------------------------------------------------------- /apps/backend/src/upload/dtos/imageUploadResponse.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsString } from 'class-validator'; 3 | 4 | export class ImageUploadResponseDto { 5 | @ApiProperty({ 6 | example: '이미지 업로드 성공', 7 | description: 'api 요청 결과 메시지', 8 | }) 9 | @IsString() 10 | message: string; 11 | 12 | @ApiProperty({ 13 | example: 'https://kr.object.ncloudstorage.com/octodocs-static/uploads/name', 14 | description: '업로드된 이미지 url', 15 | }) 16 | @IsString() 17 | url: string; 18 | } 19 | -------------------------------------------------------------------------------- /apps/backend/src/upload/s3-client.provider.ts: -------------------------------------------------------------------------------- 1 | import { S3Client } from '@aws-sdk/client-s3'; 2 | import { Provider } from '@nestjs/common'; 3 | import { ConfigService } from '@nestjs/config'; 4 | 5 | export const S3_CLIENT = 'S3_CLIENT'; 6 | 7 | export const s3ClientProvider: Provider = { 8 | provide: S3_CLIENT, 9 | useFactory: (configService: ConfigService) => { 10 | return new S3Client({ 11 | region: configService.get('CLOUD_REGION'), 12 | endpoint: configService.get('CLOUD_ENDPOINT'), 13 | credentials: { 14 | accessKeyId: configService.get('CLOUD_ACCESS_KEY_ID'), 15 | secretAccessKey: configService.get('CLOUD_SECRET_ACCESS_KEY'), 16 | }, 17 | }); 18 | }, 19 | inject: [ConfigService], 20 | }; 21 | -------------------------------------------------------------------------------- /apps/backend/src/upload/upload.config.ts: -------------------------------------------------------------------------------- 1 | import { InvalidFileException } from '../exception/upload.exception'; 2 | 3 | export const MAX_FILE_SIZE = 1024 * 1024 * 5; // 5MB 4 | 5 | export const imageFileFilter = ( 6 | req: any, 7 | file: Express.Multer.File, 8 | callback: (error: Error | null, acceptFile: boolean) => void, 9 | ) => { 10 | if (!file.originalname.match(/\.(jpg|jpeg|png|gif)$/)) { 11 | return callback(new InvalidFileException(), false); 12 | } 13 | callback(null, true); 14 | }; 15 | 16 | export const uploadOptions = { 17 | fileFilter: imageFileFilter, 18 | limits: { 19 | fileSize: MAX_FILE_SIZE, 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /apps/backend/src/upload/upload.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Post, 4 | UploadedFile, 5 | UseInterceptors, 6 | } from '@nestjs/common'; 7 | import { FileInterceptor } from '@nestjs/platform-express'; 8 | import { UploadService } from './upload.service'; 9 | import { uploadOptions } from './upload.config'; 10 | import { ImageUploadResponseDto } from './dtos/imageUploadResponse.dto'; 11 | 12 | export enum UploadResponseMessage { 13 | UPLOAD_IMAGE_SUCCESS = '이미지 업로드 성공', 14 | } 15 | 16 | @Controller('upload') 17 | export class UploadController { 18 | constructor(private readonly uploadService: UploadService) {} 19 | 20 | @Post('image') 21 | @UseInterceptors(FileInterceptor('file', uploadOptions)) 22 | async uploadImage( 23 | @UploadedFile() file: Express.Multer.File, 24 | ): Promise { 25 | const result = await this.uploadService.uploadImageToCloud(file); 26 | 27 | return { 28 | message: UploadResponseMessage.UPLOAD_IMAGE_SUCCESS, 29 | url: result, 30 | }; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /apps/backend/src/upload/upload.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { UploadService } from './upload.service'; 3 | import { UploadController } from './upload.controller'; 4 | import { ConfigModule } from '@nestjs/config'; 5 | import { s3ClientProvider } from './s3-client.provider'; 6 | 7 | @Module({ 8 | imports: [ConfigModule], 9 | controllers: [UploadController], 10 | providers: [UploadService, s3ClientProvider], 11 | exports: [UploadService], 12 | }) 13 | export class UploadModule {} 14 | -------------------------------------------------------------------------------- /apps/backend/src/upload/upload.service.ts: -------------------------------------------------------------------------------- 1 | import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; 2 | import { Inject, Injectable } from '@nestjs/common'; 3 | import { ConfigService } from '@nestjs/config'; 4 | import { S3_CLIENT } from './s3-client.provider'; 5 | 6 | @Injectable() 7 | export class UploadService { 8 | constructor( 9 | @Inject(S3_CLIENT) private readonly s3Client: S3Client, 10 | private readonly configService: ConfigService, 11 | ) {} 12 | 13 | async uploadImageToCloud(file: Express.Multer.File) { 14 | const key = `uploads/${Date.now()}-${file.originalname}`; 15 | 16 | const command = new PutObjectCommand({ 17 | Bucket: this.configService.get('CLOUD_BUCKET_NAME'), 18 | Key: key, 19 | Body: file.buffer, 20 | ContentType: file.mimetype, 21 | ACL: 'public-read', 22 | }); 23 | 24 | await this.s3Client.send(command); 25 | 26 | return `${this.configService.get('CLOUD_ENDPOINT')}/${this.configService.get('CLOUD_BUCKET_NAME')}/${key}`; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /apps/backend/src/user/user.entity.ts: -------------------------------------------------------------------------------- 1 | // user.entity.ts 2 | import { 3 | Entity, 4 | PrimaryGeneratedColumn, 5 | Column, 6 | CreateDateColumn, 7 | Index, 8 | } from 'typeorm'; 9 | 10 | @Entity() 11 | export class User { 12 | @PrimaryGeneratedColumn('increment') 13 | id: number; 14 | 15 | @Column({ unique: true }) 16 | @Index() 17 | snowflakeId: string; 18 | 19 | @Column({ unique: true }) 20 | providerId: string; // 네이버/카카오 ID 21 | 22 | @Column() 23 | provider: string; // 'naver' 또는 'kakao' 24 | 25 | @Column() 26 | email: string; 27 | 28 | @Column({ 29 | default: '#FF8A8A', 30 | }) 31 | cursorColor: string; 32 | 33 | @Column({ nullable: true }) 34 | nickname: string; 35 | 36 | @Column({ nullable: true }) 37 | profileImage: string; 38 | 39 | @Column({ nullable: true }) 40 | refreshToken: string; 41 | 42 | @CreateDateColumn() 43 | createdAt: Date; 44 | } 45 | -------------------------------------------------------------------------------- /apps/backend/src/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { User } from './user.entity'; 4 | import { UserRepository } from './user.repository'; 5 | 6 | @Module({ 7 | imports: [TypeOrmModule.forFeature([User])], 8 | providers: [UserRepository], 9 | exports: [TypeOrmModule, UserRepository], 10 | }) 11 | export class UserModule {} 12 | -------------------------------------------------------------------------------- /apps/backend/src/user/user.repository.ts: -------------------------------------------------------------------------------- 1 | // user.repository.ts 2 | import { DataSource, Repository } from 'typeorm'; 3 | import { User } from './user.entity'; 4 | import { Injectable } from '@nestjs/common'; 5 | import { InjectDataSource } from '@nestjs/typeorm'; 6 | 7 | @Injectable() 8 | export class UserRepository extends Repository { 9 | constructor(@InjectDataSource() private dataSource: DataSource) { 10 | super(User, dataSource.createEntityManager()); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /apps/backend/src/workspace/dtos/createWorkspace.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsNotEmpty, IsString, IsOptional, IsIn } from 'class-validator'; 3 | 4 | export class CreateWorkspaceDto { 5 | @ApiProperty({ 6 | example: 'naver-boostcamp-9th', 7 | description: '워크스페이스 제목입니다.', 8 | }) 9 | @IsNotEmpty() 10 | @IsString() 11 | title: string; 12 | 13 | @ApiProperty({ 14 | example: '네이버 부스트캠프 9기 워크스페이스입니다', 15 | description: '워크스페이스 설명입니다.', 16 | }) 17 | @IsOptional() 18 | @IsString() 19 | description?: string; 20 | 21 | @ApiProperty({ 22 | example: 'private', 23 | description: '워크스페이스 전체 공개 여부입니다. default는 private입니다.', 24 | }) 25 | @IsOptional() 26 | @IsIn(['public', 'private']) 27 | visibility?: 'public' | 'private'; 28 | 29 | @ApiProperty({ 30 | example: 'thumbnail-url-1', 31 | description: '워크스페이스 표지 이미지 url입니다.', 32 | }) 33 | @IsOptional() 34 | @IsString() 35 | thumbnailUrl?: string; 36 | } 37 | -------------------------------------------------------------------------------- /apps/backend/src/workspace/dtos/createWorkspaceInviteUrl.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsString } from 'class-validator'; 3 | 4 | export class CreateWorkspaceInviteUrlDto { 5 | @ApiProperty({ 6 | example: 'OO 생성에 성공했습니다.', 7 | description: 'api 요청 결과 메시지', 8 | }) 9 | @IsString() 10 | message: string; 11 | 12 | @ApiProperty({ 13 | example: 'https://octodocs.local/api/workspace/join?token=12345', 14 | description: '워크스페이스 초대용 링크입니다.', 15 | }) 16 | @IsString() 17 | inviteUrl: string; 18 | } 19 | -------------------------------------------------------------------------------- /apps/backend/src/workspace/dtos/createWorkspaceResponse.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsString } from 'class-validator'; 3 | 4 | export class CreateWorkspaceResponseDto { 5 | @ApiProperty({ 6 | example: 'OO 생성에 성공했습니다.', 7 | description: 'api 요청 결과 메시지', 8 | }) 9 | @IsString() 10 | message: string; 11 | 12 | @ApiProperty({ 13 | example: 'snowflake-id-1', 14 | description: '페이지의 외부 키 (snowflakeId)', 15 | }) 16 | @IsString() 17 | workspaceId: string; 18 | } 19 | -------------------------------------------------------------------------------- /apps/backend/src/workspace/dtos/getUserWorkspacesResponse.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsString, IsArray } from 'class-validator'; 3 | import { UserWorkspaceDto } from './userWorkspace.dto'; 4 | 5 | export class GetUserWorkspacesResponseDto { 6 | @ApiProperty({ 7 | example: 'OO 생성에 성공했습니다.', 8 | description: 'api 요청 결과 메시지', 9 | }) 10 | @IsString() 11 | message: string; 12 | 13 | @ApiProperty({ 14 | example: [ 15 | { 16 | workspaceId: 'snowflake-id-1', 17 | title: 'naver-boostcamp-9th', 18 | description: '네이버 부스트캠프 9기 워크스페이스입니다', 19 | thumbnailUrl: 'https://example.com/image1.png', 20 | role: 'guest', 21 | visibility: 'private', 22 | }, 23 | { 24 | workspaceId: 'snowflake-id-2', 25 | title: '2024-fall-컴퓨터구조', 26 | description: null, 27 | thumbnailUrl: null, 28 | role: 'owner', 29 | visibility: 'public', 30 | }, 31 | ], 32 | description: '사용자가 속한 모든 워크스페이스 배열', 33 | }) 34 | @IsArray() 35 | workspaces: UserWorkspaceDto[]; 36 | } 37 | -------------------------------------------------------------------------------- /apps/backend/src/workspace/dtos/getWorkspaceResponse.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsString } from 'class-validator'; 3 | import { UserWorkspaceDto } from './userWorkspace.dto'; 4 | 5 | export class GetWorkspaceResponseDto { 6 | @ApiProperty({ 7 | example: 'OO 생성에 성공했습니다.', 8 | description: 'api 요청 결과 메시지', 9 | }) 10 | @IsString() 11 | message: string; 12 | 13 | @ApiProperty({ 14 | example: [ 15 | { 16 | workspaceId: 'snowflake-id-1', 17 | title: 'naver-boostcamp-9th', 18 | description: '네이버 부스트캠프 9기 워크스페이스입니다', 19 | thumbnailUrl: 'https://example.com/image1.png', 20 | role: 'guest', 21 | visibility: 'private', 22 | }, 23 | ], 24 | description: '사용자가 접근하려고 하는 워크스페이스 데이터', 25 | }) 26 | workspace: UserWorkspaceDto; 27 | } 28 | -------------------------------------------------------------------------------- /apps/backend/src/workspace/dtos/messageResponse.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsString } from 'class-validator'; 3 | 4 | export class MessageResponseDto { 5 | @ApiProperty({ 6 | example: 'OO 생성에 성공했습니다.', 7 | description: 'api 요청 결과 메시지', 8 | }) 9 | @IsString() 10 | message: string; 11 | } 12 | -------------------------------------------------------------------------------- /apps/backend/src/workspace/dtos/userWorkspace.dto.ts: -------------------------------------------------------------------------------- 1 | export class UserWorkspaceDto { 2 | workspaceId: string; 3 | title: string; 4 | description: string | null; 5 | thumbnailUrl: string | null; 6 | role: 'owner' | 'guest' | null; 7 | visibility: 'public' | 'private'; 8 | } 9 | -------------------------------------------------------------------------------- /apps/backend/src/workspace/workspace.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | Entity, 4 | ManyToOne, 5 | PrimaryGeneratedColumn, 6 | CreateDateColumn, 7 | UpdateDateColumn, 8 | OneToMany, 9 | Index, 10 | } from 'typeorm'; 11 | import { User } from '../user/user.entity'; 12 | import { Edge } from '../edge/edge.entity'; 13 | import { Page } from '../page/page.entity'; 14 | import { Node } from '../node/node.entity'; 15 | 16 | @Entity() 17 | export class Workspace { 18 | @PrimaryGeneratedColumn() 19 | id: number; 20 | 21 | @Column({ unique: true }) 22 | @Index() 23 | snowflakeId: string; 24 | 25 | @ManyToOne(() => User, { nullable: false }) 26 | owner: User; 27 | 28 | @Column() 29 | title: string; 30 | 31 | @Column({ nullable: true }) 32 | description: string; 33 | 34 | @Column({ default: 'private' }) 35 | visibility: 'public' | 'private'; 36 | 37 | @CreateDateColumn() 38 | createdAt: Date; 39 | 40 | @UpdateDateColumn() 41 | updatedAt: Date; 42 | 43 | @Column({ nullable: true }) 44 | thumbnailUrl: string; 45 | 46 | @OneToMany(() => Edge, (edge) => edge.workspace) 47 | edges: Edge[]; 48 | 49 | @OneToMany(() => Page, (page) => page.workspace) 50 | pages: Page[]; 51 | 52 | @OneToMany(() => Node, (node) => node.workspace) 53 | nodes: Node[]; 54 | } 55 | -------------------------------------------------------------------------------- /apps/backend/src/workspace/workspace.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { WorkspaceService } from './workspace.service'; 3 | import { WorkspaceController } from './workspace.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { Workspace } from './workspace.entity'; 6 | import { WorkspaceRepository } from './workspace.repository'; 7 | import { UserModule } from '../user/user.module'; 8 | import { RoleModule } from '../role/role.module'; 9 | import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; 10 | import { TokenModule } from '../auth/token/token.module'; 11 | import { TokenService } from '../auth/token/token.service'; 12 | import { ConfigModule } from '@nestjs/config'; 13 | 14 | @Module({ 15 | imports: [ 16 | TypeOrmModule.forFeature([Workspace]), 17 | UserModule, 18 | RoleModule, 19 | TokenModule, 20 | ConfigModule, 21 | ], 22 | controllers: [WorkspaceController], 23 | providers: [ 24 | WorkspaceService, 25 | WorkspaceRepository, 26 | JwtAuthGuard, 27 | TokenService, 28 | ], 29 | exports: [WorkspaceService, WorkspaceRepository], 30 | }) 31 | export class WorkspaceModule {} 32 | -------------------------------------------------------------------------------- /apps/backend/src/workspace/workspace.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { DataSource, Repository } from 'typeorm'; 3 | import { Workspace } from './workspace.entity'; 4 | import { InjectDataSource } from '@nestjs/typeorm'; 5 | 6 | @Injectable() 7 | export class WorkspaceRepository extends Repository { 8 | constructor(@InjectDataSource() private dataSource: DataSource) { 9 | super(Workspace, dataSource.createEntityManager()); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /apps/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 | -------------------------------------------------------------------------------- /apps/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 | -------------------------------------------------------------------------------- /apps/backend/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /apps/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 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false, 20 | }, 21 | "watchOptions": { 22 | "watchFile": "fixedPollingInterval" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /apps/frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": ["**/routeTree.gen.ts", "dist"], 3 | "printWidth": 80, 4 | "tabWidth": 2, 5 | "plugins": ["prettier-plugin-tailwindcss"] 6 | } 7 | -------------------------------------------------------------------------------- /apps/frontend/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | export default tseslint.config({ 18 | languageOptions: { 19 | // other options... 20 | parserOptions: { 21 | project: ['./tsconfig.node.json', './tsconfig.app.json'], 22 | tsconfigRootDir: import.meta.dirname, 23 | }, 24 | }, 25 | }) 26 | ``` 27 | 28 | - Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` 29 | - Optionally add `...tseslint.configs.stylisticTypeChecked` 30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: 31 | 32 | ```js 33 | // eslint.config.js 34 | import react from 'eslint-plugin-react' 35 | 36 | export default tseslint.config({ 37 | // Set the react version 38 | settings: { react: { version: '18.3' } }, 39 | plugins: { 40 | // Add the react plugin 41 | react, 42 | }, 43 | rules: { 44 | // other rules... 45 | // Enable its recommended rules 46 | ...react.configs.recommended.rules, 47 | ...react.configs['jsx-runtime'].rules, 48 | }, 49 | }) 50 | ``` 51 | -------------------------------------------------------------------------------- /apps/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": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/editor/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } 22 | -------------------------------------------------------------------------------- /apps/frontend/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import globals from "globals"; 3 | import reactHooks from "eslint-plugin-react-hooks"; 4 | import reactRefresh from "eslint-plugin-react-refresh"; 5 | import tseslint from "typescript-eslint"; 6 | 7 | export default tseslint.config( 8 | { ignores: ["dist", "**/routeTree.gen.ts"] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ["**/*.{ts,tsx}"], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | "react-hooks": reactHooks, 18 | "react-refresh": reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | "react-refresh/only-export-components": [ 23 | "warn", 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ); 29 | -------------------------------------------------------------------------------- /apps/frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Octodocs 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /apps/frontend/public/fonts/Pretendard-Bold.subset.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web15-OctoDocs/15781ae9611c85acd25ce7237fe74decb45a0569/apps/frontend/public/fonts/Pretendard-Bold.subset.woff2 -------------------------------------------------------------------------------- /apps/frontend/public/fonts/Pretendard-Medium.subset.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web15-OctoDocs/15781ae9611c85acd25ce7237fe74decb45a0569/apps/frontend/public/fonts/Pretendard-Medium.subset.woff2 -------------------------------------------------------------------------------- /apps/frontend/public/fonts/Pretendard-SemiBold.subset.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web15-OctoDocs/15781ae9611c85acd25ce7237fe74decb45a0569/apps/frontend/public/fonts/Pretendard-SemiBold.subset.woff2 -------------------------------------------------------------------------------- /apps/frontend/public/icons/kakao.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /apps/frontend/public/icons/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web15-OctoDocs/15781ae9611c85acd25ce7237fe74decb45a0569/apps/frontend/public/icons/logo.png -------------------------------------------------------------------------------- /apps/frontend/public/icons/naver.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /apps/frontend/public/icons/warning-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web15-OctoDocs/15781ae9611c85acd25ce7237fe74decb45a0569/apps/frontend/public/icons/warning-icon.png -------------------------------------------------------------------------------- /apps/frontend/public/icons/workspace-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /apps/frontend/src/app/App.tsx: -------------------------------------------------------------------------------- 1 | import { useSyncedUsers } from "@/entities/user"; 2 | import { useProtectedWorkspace } from "@/features/workspace"; 3 | import { CanvasView } from "@/widgets/CanvasView"; 4 | import { EditorView } from "@/widgets/EditorView"; 5 | import { NodeToolsView } from "@/widgets/NodeToolsView"; 6 | import { PageSideBarView } from "@/widgets/PageSideBarView"; 7 | import { CanvasToolsView } from "@/widgets/CanvasToolsView"; 8 | import { SideWrapper } from "@/shared/ui"; 9 | 10 | function App() { 11 | useSyncedUsers(); 12 | const { isLoading } = useProtectedWorkspace(); 13 | 14 | if (isLoading) { 15 | return ( 16 |
17 |
Loading...
18 |
19 | ); 20 | } 21 | 22 | return ( 23 |
24 | 25 | 26 | 27 | 28 | 32 | 33 | 34 | 35 | 36 |
37 | ); 38 | } 39 | 40 | export default App; 41 | -------------------------------------------------------------------------------- /apps/frontend/src/app/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import "../shared/index.css"; 4 | import { TanstackQueryProvider } from "./providers/TanstackQueryProvider"; 5 | import { TanstackRouterProvider } from "./providers/TanstackRouterProvider"; 6 | 7 | createRoot(document.getElementById("root")!).render( 8 | 9 | 10 | 11 | 12 | , 13 | ); 14 | -------------------------------------------------------------------------------- /apps/frontend/src/app/providers/TanstackQueryProvider.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 2 | 3 | const queryClient = new QueryClient(); 4 | 5 | export function TanstackQueryProvider({ 6 | children, 7 | }: { 8 | children: React.ReactNode; 9 | }) { 10 | return ( 11 | {children} 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /apps/frontend/src/app/providers/TanstackRouterProvider.tsx: -------------------------------------------------------------------------------- 1 | import { RouterProvider, createRouter } from "@tanstack/react-router"; 2 | import { routeTree } from "@/app/routeTree.gen"; 3 | 4 | const router = createRouter({ routeTree }); 5 | declare module "@tanstack/react-router" { 6 | interface Register { 7 | router: typeof router; 8 | } 9 | } 10 | 11 | export function TanstackRouterProvider() { 12 | return ; 13 | } 14 | -------------------------------------------------------------------------------- /apps/frontend/src/app/routes/__root.tsx: -------------------------------------------------------------------------------- 1 | import { createRootRoute, Outlet } from "@tanstack/react-router"; 2 | 3 | export const Route = createRootRoute({ 4 | component: () => , 5 | }); 6 | -------------------------------------------------------------------------------- /apps/frontend/src/app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute } from "@tanstack/react-router"; 2 | import App from "@/app/App"; 3 | 4 | export const Route = createFileRoute("/")({ 5 | component: App, 6 | }); 7 | -------------------------------------------------------------------------------- /apps/frontend/src/app/routes/workspace/$workspaceId.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute } from "@tanstack/react-router"; 2 | import App from "@/app/App"; 3 | 4 | export const Route = createFileRoute("/workspace/$workspaceId")({ 5 | component: App, 6 | }); 7 | -------------------------------------------------------------------------------- /apps/frontend/src/entities/node/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | type Node, 3 | type NoteNodeData, 4 | type NoteNodeType, 5 | } from "./model/nodeTypes"; 6 | 7 | export { MemoizedGroupNode } from "./ui/GroupNode"; 8 | export { NoteNode } from "./ui/NoteNode"; 9 | -------------------------------------------------------------------------------- /apps/frontend/src/entities/node/model/nodeTypes.ts: -------------------------------------------------------------------------------- 1 | import { type Node as FlowNode } from "@xyflow/react"; 2 | 3 | export interface Node extends FlowNode { 4 | isHolding: boolean; 5 | } 6 | 7 | export type NoteNodeData = { 8 | title: string; 9 | id: number; 10 | emoji: string; 11 | color: string; 12 | }; 13 | 14 | export type NoteNodeType = FlowNode; 15 | -------------------------------------------------------------------------------- /apps/frontend/src/entities/node/ui/GroupNode/index.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from "react"; 2 | import { Maximize2 } from "lucide-react"; 3 | import { NodeResizeControl } from "@xyflow/react"; 4 | 5 | const controlStyle = { 6 | background: "transparent", 7 | border: "none", 8 | }; 9 | 10 | function GroupNode() { 11 | return ( 12 | <> 13 | 14 | 18 | 19 | 20 | ); 21 | } 22 | 23 | export const MemoizedGroupNode = memo(GroupNode); 24 | -------------------------------------------------------------------------------- /apps/frontend/src/entities/node/ui/NoteNode/index.tsx: -------------------------------------------------------------------------------- 1 | import { FileText } from "lucide-react"; 2 | import { Handle, NodeProps, Position } from "@xyflow/react"; 3 | 4 | import { NoteNodeType } from "../../model/nodeTypes"; 5 | import { type User } from "@/entities/user"; 6 | import { ActiveUser, Emoji } from "@/shared/ui"; 7 | 8 | interface NoteNodeProps extends NodeProps { 9 | isClicked: boolean; 10 | handleNodeClick: () => void; 11 | users: User[]; 12 | } 13 | 14 | export function NoteNode({ 15 | data, 16 | isClicked, 17 | users, 18 | handleNodeClick, 19 | }: NoteNodeProps) { 20 | return ( 21 |
26 | 32 | 38 | 44 | 50 |
51 |
52 | {data.emoji ? ( 53 | 54 | ) : ( 55 | 60 | )} 61 |
{data.title}
62 |
63 | 64 |
65 |
66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /apps/frontend/src/entities/page/api/pageApi.ts: -------------------------------------------------------------------------------- 1 | import { Get, Post, Delete, Patch } from "@/shared/api"; 2 | import { 3 | GetPageResponse, 4 | GetPagesResponse, 5 | CreatePageRequest, 6 | CreatePageResponse, 7 | UpdatePageRequest, 8 | } from "../model/pageTypes"; 9 | 10 | export const getPage = async (id: number) => { 11 | const url = `/api/page/${id}`; 12 | 13 | const res = await Get(url); 14 | return res.data.page; 15 | }; 16 | 17 | // TODO: 임시 18 | export const getPages = async (workspaceId: string) => { 19 | const url = `/api/page/workspace/${workspaceId}`; 20 | 21 | const res = await Get(url); 22 | return res.data.pages; 23 | }; 24 | 25 | export const createPage = async (pageData: CreatePageRequest) => { 26 | const url = `/api/page`; 27 | 28 | const res = await Post(url, pageData); 29 | return res.data; 30 | }; 31 | 32 | export const deletePage = async (id: number) => { 33 | const url = `/api/page/${id}`; 34 | 35 | const res = await Delete(url); 36 | return res.data; 37 | }; 38 | 39 | export const updatePage = async (id: number, pageData: UpdatePageRequest) => { 40 | const url = `/api/page/${id}`; 41 | 42 | const res = await Patch(url, pageData); 43 | return res.data; 44 | }; 45 | -------------------------------------------------------------------------------- /apps/frontend/src/entities/page/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | getPage, 3 | getPages, 4 | createPage, 5 | deletePage, 6 | updatePage, 7 | } from "./api/pageApi"; 8 | export { useCreatePage, useDeletePage } from "./model/pageMutations"; 9 | 10 | export { usePageStore } from "./model/pageStore"; 11 | export { type Page, type CreatePageRequest } from "./model/pageTypes"; 12 | -------------------------------------------------------------------------------- /apps/frontend/src/entities/page/model/pageMutations.ts: -------------------------------------------------------------------------------- 1 | import { useMutation } from "@tanstack/react-query"; 2 | 3 | import { CreatePageRequest } from "./pageTypes"; 4 | import { createPage, deletePage } from "../api/pageApi"; 5 | 6 | export const useCreatePage = () => { 7 | return useMutation({ 8 | mutationFn: ({ 9 | title, 10 | content, 11 | x, 12 | y, 13 | emoji, 14 | workspaceId, 15 | }: CreatePageRequest) => 16 | createPage({ title, content, x, y, emoji, workspaceId }), 17 | }); 18 | }; 19 | 20 | export const useDeletePage = () => { 21 | return useMutation({ 22 | mutationFn: ({ id }: { id: number }) => deletePage(id), 23 | }); 24 | }; 25 | -------------------------------------------------------------------------------- /apps/frontend/src/entities/page/model/pageStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | interface PageStore { 4 | currentPage: number | null; 5 | setCurrentPage: (currentPage: number | null) => void; 6 | } 7 | 8 | export const usePageStore = create((set) => ({ 9 | currentPage: null, 10 | setCurrentPage: (currentPage: number | null) => set({ currentPage }), 11 | })); 12 | -------------------------------------------------------------------------------- /apps/frontend/src/entities/page/model/pageTypes.ts: -------------------------------------------------------------------------------- 1 | import { JSONContent } from "novel"; 2 | 3 | export interface Page { 4 | id: number; 5 | title: string; 6 | content: JSONContent; 7 | emoji: string | null; 8 | } 9 | 10 | export interface GetPageResponse { 11 | message: string; 12 | page: Page; 13 | } 14 | 15 | export interface GetPagesResponse { 16 | message: string; 17 | pages: Omit[]; 18 | } 19 | 20 | export interface CreatePageRequest { 21 | title: string; 22 | content: JSONContent; 23 | emoji: string | null; 24 | x: number; 25 | y: number; 26 | workspaceId: string; 27 | } 28 | 29 | export interface CreatePageResponse { 30 | message: string; 31 | pageId: number; 32 | } 33 | 34 | export interface UpdatePageRequest { 35 | title: string; 36 | content: JSONContent; 37 | emoji: string | null; 38 | } 39 | -------------------------------------------------------------------------------- /apps/frontend/src/entities/user/index.ts: -------------------------------------------------------------------------------- 1 | export { useUserStore, type User } from "./model/userStore"; 2 | export { useSyncedUsers } from "./model/useSyncedUsers"; 3 | 4 | export { UserProfile } from "./ui/UserProfile"; 5 | -------------------------------------------------------------------------------- /apps/frontend/src/entities/user/model/useSyncedUsers.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | import { useUserStore, type User } from "./userStore"; 4 | import { usePageStore } from "@/entities/page"; 5 | import { useUserConnection } from "./useUserConnection"; 6 | import useConnectionStore from "@/shared/model/useConnectionStore"; 7 | 8 | export const useSyncedUsers = () => { 9 | const { currentPage } = usePageStore(); 10 | useUserConnection(); 11 | const { user } = useConnectionStore(); 12 | const { currentUser, setCurrentUser, setUsers } = useUserStore(); 13 | 14 | const updateUsersFromAwareness = () => { 15 | if (!user.provider) return; 16 | 17 | const values = Array.from( 18 | user.provider.awareness.getStates().values(), 19 | ) as User[]; 20 | 21 | setUsers(values); 22 | }; 23 | 24 | const getLocalStorageUser = (): User | null => { 25 | const userData = localStorage.getItem("currentUser"); 26 | return userData ? JSON.parse(userData) : null; 27 | }; 28 | 29 | useEffect(() => { 30 | if (currentPage === null) return; 31 | if (!user.provider) return; 32 | 33 | const updatedUser: User = { 34 | ...currentUser, 35 | currentPageId: currentPage.toString(), 36 | }; 37 | 38 | setCurrentUser(updatedUser); 39 | user.provider.awareness.setLocalState(updatedUser); 40 | }, [currentPage, user.provider]); 41 | 42 | useEffect(() => { 43 | if (!user.provider) return; 44 | 45 | const localStorageUser = getLocalStorageUser(); 46 | 47 | if (!localStorageUser) { 48 | localStorage.setItem("currentUser", JSON.stringify(currentUser)); 49 | } else { 50 | setCurrentUser(localStorageUser); 51 | user.provider.awareness.setLocalState(localStorageUser); 52 | } 53 | 54 | updateUsersFromAwareness(); 55 | 56 | user.provider.awareness.on("change", updateUsersFromAwareness); 57 | 58 | return () => { 59 | if (!user.provider) return; 60 | user.provider.awareness.off("change", updateUsersFromAwareness); 61 | }; 62 | }, [user.provider]); 63 | }; 64 | -------------------------------------------------------------------------------- /apps/frontend/src/entities/user/model/useUserConnection.ts: -------------------------------------------------------------------------------- 1 | // provider: createSocketIOProvider("users", new Y.Doc()), 2 | import * as Y from "yjs"; 3 | import { createSocketIOProvider } from "@/shared/api"; 4 | import useConnectionStore from "@/shared/model/useConnectionStore"; 5 | import { useEffect } from "react"; 6 | 7 | export const useUserConnection = () => { 8 | const { user, setProvider, setConnectionStatus } = useConnectionStore(); 9 | 10 | useEffect(() => { 11 | if (user.provider) { 12 | user.provider.destroy(); 13 | } 14 | 15 | setConnectionStatus("user", "connecting"); 16 | 17 | const provider = createSocketIOProvider("users", new Y.Doc()); 18 | setProvider("user", provider); 19 | 20 | provider.on( 21 | "status", 22 | ({ status }: { status: "connected" | "disconnected" }) => { 23 | setConnectionStatus("user", status); 24 | }, 25 | ); 26 | 27 | return () => { 28 | provider.doc.destroy(); 29 | provider.destroy(); 30 | }; 31 | }, []); 32 | }; 33 | -------------------------------------------------------------------------------- /apps/frontend/src/entities/user/model/userStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | import { getRandomColor, getRandomHexString } from "@/shared/lib"; 4 | 5 | export interface User { 6 | clientId: string; 7 | color: string; 8 | currentPageId: string | null; 9 | } 10 | 11 | interface UserStore { 12 | users: User[]; 13 | currentUser: User; 14 | setUsers: (users: User[]) => void; 15 | setCurrentUser: (user: User) => void; 16 | } 17 | 18 | export const useUserStore = create((set) => ({ 19 | users: [], 20 | currentUser: { 21 | clientId: getRandomHexString(10), 22 | color: getRandomColor(), 23 | currentPageId: null, 24 | }, 25 | setUsers: (users: User[]) => set({ users }), 26 | setCurrentUser: (user: User) => set({ currentUser: user }), 27 | })); 28 | -------------------------------------------------------------------------------- /apps/frontend/src/entities/user/ui/UserProfile/index.tsx: -------------------------------------------------------------------------------- 1 | type UserProfileProps = { 2 | nickname: string; 3 | }; 4 | 5 | export function UserProfile({ nickname }: UserProfileProps) { 6 | return ( 7 |
8 |
9 |
{nickname}
10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /apps/frontend/src/features/auth/api/authApi.ts: -------------------------------------------------------------------------------- 1 | import { Get, Post } from "@/shared/api"; 2 | 3 | interface GetUserResponse { 4 | message: string; 5 | snowflakeId: string; 6 | } 7 | 8 | export const getUser = async () => { 9 | const url = `/api/auth/profile`; 10 | 11 | const res = await Get(url); 12 | return res.data; 13 | }; 14 | 15 | export const logout = async () => { 16 | const url = "/api/auth/logout"; 17 | 18 | await Post(url); 19 | }; 20 | -------------------------------------------------------------------------------- /apps/frontend/src/features/auth/index.ts: -------------------------------------------------------------------------------- 1 | export { useLogout } from "./model/authMutations"; 2 | export { useGetUser } from "./model/authQueries"; 3 | 4 | export { LoginForm } from "./ui/LoginForm"; 5 | export { Logout } from "./ui/Logout"; 6 | -------------------------------------------------------------------------------- /apps/frontend/src/features/auth/model/authMutations.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from "@tanstack/react-query"; 2 | 3 | import { logout } from "../api/authApi"; 4 | 5 | export const useLogout = () => { 6 | const queryClient = useQueryClient(); 7 | 8 | const logoutMutation = useMutation({ 9 | mutationFn: logout, 10 | onSuccess: async () => { 11 | queryClient.setQueryData(["user"], null); 12 | await queryClient.invalidateQueries({ queryKey: ["user"] }); 13 | window.location.replace("/"); 14 | }, 15 | }); 16 | 17 | return logoutMutation; 18 | }; 19 | -------------------------------------------------------------------------------- /apps/frontend/src/features/auth/model/authQueries.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | 3 | import { getUser } from "../api/authApi"; 4 | 5 | export const useGetUser = () => { 6 | return useQuery({ 7 | queryKey: ["user"], 8 | queryFn: getUser, 9 | retry: false, 10 | refetchOnWindowFocus: false, 11 | }); 12 | }; 13 | -------------------------------------------------------------------------------- /apps/frontend/src/features/auth/model/useAuth.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 2 | 3 | import { getUser, logout } from "../api/authApi"; 4 | 5 | export const useGetUser = () => { 6 | return useQuery({ 7 | queryKey: ["user"], 8 | queryFn: getUser, 9 | retry: false, 10 | refetchOnWindowFocus: false, 11 | }); 12 | }; 13 | 14 | export const useLogout = () => { 15 | const queryClient = useQueryClient(); 16 | 17 | const logoutMutation = useMutation({ 18 | mutationFn: logout, 19 | onSuccess: async () => { 20 | queryClient.setQueryData(["user"], null); 21 | await queryClient.invalidateQueries({ queryKey: ["user"] }); 22 | }, 23 | }); 24 | 25 | return logoutMutation; 26 | }; 27 | -------------------------------------------------------------------------------- /apps/frontend/src/features/auth/ui/LoginForm/index.tsx: -------------------------------------------------------------------------------- 1 | import KakaoLogo from "/icons/kakao.svg"; 2 | import NaverLogo from "/icons/naver.svg"; 3 | 4 | export function LoginForm() { 5 | return ( 6 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /apps/frontend/src/features/auth/ui/Logout/index.tsx: -------------------------------------------------------------------------------- 1 | import { LogOut } from "lucide-react"; 2 | 3 | import { useLogout } from "../../model/authMutations"; 4 | import { Button } from "@/shared/ui"; 5 | import { usePopover } from "@/shared/model"; 6 | 7 | export function Logout() { 8 | const logoutMutation = useLogout(); 9 | const { setOpen } = usePopover(); 10 | 11 | const handleButtonClick = () => { 12 | setOpen(false); 13 | logoutMutation.mutate(); 14 | }; 15 | 16 | return ( 17 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /apps/frontend/src/features/canvas/index.ts: -------------------------------------------------------------------------------- 1 | export { Canvas } from "@/features/canvas/ui/Canvas"; 2 | -------------------------------------------------------------------------------- /apps/frontend/src/features/canvas/lib/updateNodesMap.ts: -------------------------------------------------------------------------------- 1 | import * as Y from "yjs"; 2 | import { YEvent } from "yjs"; 3 | 4 | import { Node } from "@/entities/node"; 5 | 6 | export const updateNodesMapByYTextEvent = ( 7 | event: YEvent[], 8 | nodesMap: Y.Map, 9 | key: "title" | "emoji", 10 | ) => { 11 | if (!event[0].path.length) return; 12 | 13 | const pageId = event[0].path[0].toString().split("_")[1]; 14 | const value = event[0].target.toString(); 15 | 16 | const existingNode = nodesMap.get(pageId) as Node; 17 | if (!existingNode) return; 18 | 19 | const newNode: Node = { 20 | ...existingNode, 21 | data: { ...existingNode.data, id: pageId, [key]: value }, 22 | selected: false, 23 | isHolding: false, 24 | }; 25 | 26 | nodesMap.set(pageId, newNode); 27 | }; 28 | -------------------------------------------------------------------------------- /apps/frontend/src/features/canvas/model/calculateHandles.ts: -------------------------------------------------------------------------------- 1 | import { Position, Node } from "@xyflow/react"; 2 | 3 | export const getHandlePosition = (node: Node, handleId: Position) => { 4 | const nodeElement = document.querySelector(`[data-id="${node.id}"]`); 5 | const nodeRect = nodeElement!.getBoundingClientRect(); 6 | const nodeWidth = nodeRect.width; 7 | const nodeHeight = nodeRect.height; 8 | 9 | const positions = { 10 | [Position.Left]: { 11 | x: node.position.x, 12 | y: node.position.y + nodeHeight / 2, 13 | }, 14 | [Position.Right]: { 15 | x: node.position.x + nodeWidth, 16 | y: node.position.y + nodeHeight / 2, 17 | }, 18 | [Position.Top]: { 19 | x: node.position.x + nodeWidth / 2, 20 | y: node.position.y, 21 | }, 22 | [Position.Bottom]: { 23 | x: node.position.x + nodeWidth / 2, 24 | y: node.position.y + nodeHeight, 25 | }, 26 | }; 27 | 28 | return positions[handleId]; 29 | }; 30 | 31 | export const calculateBestHandles = (sourceNode: Node, targetNode: Node) => { 32 | const handlePositions = [ 33 | Position.Left, 34 | Position.Right, 35 | Position.Top, 36 | Position.Bottom, 37 | ]; 38 | let shortestDistance = Infinity; 39 | let bestHandles = { 40 | source: handlePositions[0], 41 | target: handlePositions[0], 42 | }; 43 | 44 | handlePositions.forEach((sourceHandle) => { 45 | const sourcePosition = getHandlePosition(sourceNode, sourceHandle); 46 | handlePositions.forEach((targetHandle) => { 47 | const targetPosition = getHandlePosition(targetNode, targetHandle); 48 | const distance = Math.hypot( 49 | sourcePosition.x - targetPosition.x, 50 | sourcePosition.y - targetPosition.y, 51 | ); 52 | 53 | if (distance < shortestDistance) { 54 | shortestDistance = distance; 55 | bestHandles = { source: sourceHandle, target: targetHandle }; 56 | } 57 | }); 58 | }); 59 | 60 | return bestHandles; 61 | }; 62 | -------------------------------------------------------------------------------- /apps/frontend/src/features/canvas/model/getPosition.ts: -------------------------------------------------------------------------------- 1 | import { type Node } from "@xyflow/react"; 2 | 3 | export const getRelativePosition = (node: Node, parentNode: Node) => ({ 4 | x: node.position.x - parentNode.position.x, 5 | y: node.position.y - parentNode.position.y, 6 | }); 7 | 8 | export const getAbsolutePosition = (node: Node, parentNode: Node) => ({ 9 | x: parentNode.position.x + node.position.x, 10 | y: parentNode.position.y + node.position.y, 11 | }); 12 | -------------------------------------------------------------------------------- /apps/frontend/src/features/canvas/model/sortNodes.ts: -------------------------------------------------------------------------------- 1 | import { Edge, Node } from "@xyflow/react"; 2 | import ELK from "elkjs"; 3 | 4 | const elk = new ELK(); 5 | 6 | export const getSortedNodes = async (nodes: Node[], edges: Edge[]) => { 7 | const graph = { 8 | id: "root", 9 | layoutOptions: { 10 | "elk.algorithm": "force", 11 | }, 12 | children: nodes.map((node) => ({ 13 | id: node.id, 14 | width: 160, // 실제 노드 너비로 변경하기 (node.width) 15 | height: 40, // 실제 노드 높이로 변경하기 (node.height) 16 | })), 17 | edges: edges.map((edge) => ({ 18 | id: edge.id, 19 | sources: [edge.source], 20 | targets: [edge.target], 21 | })), 22 | }; 23 | 24 | const layout = await elk.layout(graph); 25 | 26 | const updatedNodes = nodes.map((node) => { 27 | const layoutNode = layout!.children!.find((n) => n.id === node.id); 28 | 29 | return { 30 | ...node, 31 | position: { 32 | x: layoutNode!.x as number, 33 | y: layoutNode!.y as number, 34 | }, 35 | }; 36 | }); 37 | 38 | return updatedNodes; 39 | }; 40 | -------------------------------------------------------------------------------- /apps/frontend/src/features/canvas/model/useCanvasConnection.ts: -------------------------------------------------------------------------------- 1 | import * as Y from "yjs"; 2 | import { createSocketIOProvider } from "@/shared/api"; 3 | import useConnectionStore from "@/shared/model/useConnectionStore"; 4 | import { useEffect } from "react"; 5 | 6 | export const useCanvasConnection = (workspaceId: string) => { 7 | const { canvas, setProvider, setConnectionStatus } = useConnectionStore(); 8 | 9 | useEffect(() => { 10 | if (canvas.provider) { 11 | canvas.provider.destroy(); 12 | } 13 | 14 | setConnectionStatus("canvas", "connecting"); 15 | 16 | const provider = createSocketIOProvider( 17 | `flow-room-${workspaceId}`, 18 | new Y.Doc(), 19 | ); 20 | setProvider("canvas", provider); 21 | 22 | provider.on( 23 | "status", 24 | ({ status }: { status: "connected" | "disconnected" }) => { 25 | setConnectionStatus("canvas", status); 26 | }, 27 | ); 28 | 29 | return () => { 30 | provider.doc.destroy(); 31 | provider.destroy(); 32 | }; 33 | }, []); 34 | }; 35 | -------------------------------------------------------------------------------- /apps/frontend/src/features/canvas/ui/CollaborativeCursors/index.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { Panel } from "@xyflow/react"; 3 | import { useReactFlow } from "@xyflow/react"; 4 | 5 | import { AwarenessState } from "../../model/useCollaborativeCursors"; 6 | import { useUserStore } from "@/entities/user"; 7 | import { Cursor } from "@/shared/ui"; 8 | 9 | interface CollaborativeCursorsProps { 10 | cursors: Map; 11 | } 12 | 13 | export function CollaborativeCursors({ cursors }: CollaborativeCursorsProps) { 14 | const { flowToScreenPosition } = useReactFlow(); 15 | const { currentUser } = useUserStore(); 16 | const validCursors = useMemo(() => { 17 | const filteredCursors = Array.from(cursors.values()).filter( 18 | (cursor) => 19 | cursor.cursor && 20 | (cursor.clientId as unknown as string) !== currentUser.clientId, 21 | ); 22 | 23 | const uniqueCursors = filteredCursors.reduce((acc, current) => { 24 | const exists = acc.find((item) => item.clientId === current.clientId); 25 | if (!exists) { 26 | acc.push(current); 27 | } 28 | return acc; 29 | }, [] as AwarenessState[]); 30 | 31 | return uniqueCursors; 32 | }, [cursors]); 33 | 34 | return ( 35 | 36 | {validCursors.map((cursor) => ( 37 | 46 | ))} 47 | 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /apps/frontend/src/features/canvasTools/index.ts: -------------------------------------------------------------------------------- 1 | export { CursorButton } from "./ui/CursorButton"; 2 | export { NodePanel } from "./ui/NodePanel"; 3 | export { ProfilePanel } from "./ui/ProfilePanel"; 4 | -------------------------------------------------------------------------------- /apps/frontend/src/features/canvasTools/ui/CursorButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { MousePointer } from "@/shared/ui"; 2 | 3 | interface CursorButtonProps { 4 | color: string; 5 | } 6 | 7 | export function CursorButton({ color }: CursorButtonProps) { 8 | return ( 9 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /apps/frontend/src/features/canvasTools/ui/CursorPreview/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useRef } from "react"; 2 | 3 | import { BiggerCursor } from "@/shared/ui"; 4 | 5 | interface CursorPreviewProps { 6 | color: string; 7 | clientId: string; 8 | defaultCoors: { x: number; y: number }; 9 | } 10 | 11 | export function CursorPreview({ 12 | color, 13 | clientId, 14 | defaultCoors, 15 | }: CursorPreviewProps) { 16 | const [coors, setCoors] = useState(defaultCoors); 17 | const previewRef = useRef(null); 18 | 19 | const handleMouseMove = (e: React.MouseEvent) => { 20 | if (previewRef.current) { 21 | const rect = previewRef.current.getBoundingClientRect(); 22 | setCoors({ 23 | x: e.clientX - rect.left, 24 | y: e.clientY - rect.top, 25 | }); 26 | } 27 | }; 28 | 29 | const handleMouseLeave = () => { 30 | setCoors(defaultCoors); 31 | }; 32 | 33 | return ( 34 |
40 | 41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /apps/frontend/src/features/canvasTools/ui/NodeForm/index.tsx: -------------------------------------------------------------------------------- 1 | import { usePopover } from "@/shared/model"; 2 | 3 | interface NodeFormProps { 4 | changeNodeColor: (color: string) => void; 5 | } 6 | 7 | export function NodeForm({ changeNodeColor }: NodeFormProps) { 8 | const { close } = usePopover(); 9 | 10 | const nodeBackgroundColors = { 11 | white: "#FFFFFF", 12 | grey: "#F1F1EF", 13 | brown: "#F4EEEE", 14 | orange: "#FBEBDD", 15 | yellow: "#FCF3DB", 16 | green: "#EDF3ED", 17 | blue: "#E7F3F8", 18 | purple: "#F7F3F8", 19 | pink: "#FBF2F5", 20 | red: "#FDEBEC", 21 | }; 22 | 23 | const handleButtonClick = (color: string) => { 24 | changeNodeColor(color); 25 | close(); 26 | }; 27 | 28 | return ( 29 |
30 |

Background color

31 |
32 | {Object.entries(nodeBackgroundColors).map(([key, color]) => { 33 | return ( 34 |
43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /apps/frontend/src/features/canvasTools/ui/NodePanel/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | import { NodeForm } from "../NodeForm"; 4 | import { Node } from "@/entities/node"; 5 | import { Popover } from "@/shared/ui"; 6 | import useConnectionStore from "@/shared/model/useConnectionStore"; 7 | 8 | interface NodePanelProps { 9 | currentPage: string; 10 | } 11 | 12 | export function NodePanel({ currentPage }: NodePanelProps) { 13 | const [currentColor, setCurrentColor] = useState("#ffffff"); 14 | const { canvas } = useConnectionStore(); 15 | 16 | const changeNodeColor = (color: string) => { 17 | if (!canvas.provider) return; 18 | 19 | const nodesMap = canvas.provider.doc.getMap("nodes"); 20 | const id = currentPage.toString(); 21 | 22 | const existingNode = nodesMap.get(id) as Node; 23 | nodesMap.set(id, { 24 | ...existingNode, 25 | data: { ...existingNode.data, color }, 26 | }); 27 | setCurrentColor(color); 28 | }; 29 | 30 | useEffect(() => { 31 | if (!canvas.provider) return; 32 | const nodesMap = canvas.provider.doc.getMap("nodes"); 33 | const currentNode = nodesMap.get(currentPage.toString()) as Node; 34 | setCurrentColor(currentNode.data.color as string); 35 | }, [currentPage]); 36 | 37 | return ( 38 | 39 | 40 | 35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /apps/frontend/src/features/editor/ui/Editor/selectors/text-buttons.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | BoldIcon, 3 | CodeIcon, 4 | ItalicIcon, 5 | StrikethroughIcon, 6 | UnderlineIcon, 7 | } from "lucide-react"; 8 | import { EditorBubbleItem, useEditor } from "novel"; 9 | 10 | import { Button } from "../ui/button"; 11 | import { cn } from "@/shared/lib"; 12 | 13 | export const TextButtons = () => { 14 | const { editor } = useEditor(); 15 | 16 | if (!editor) { 17 | return null; 18 | } 19 | 20 | const items: SelectorItem[] = [ 21 | { 22 | name: "bold", 23 | isActive: (editor) => editor.isActive("bold"), 24 | command: (editor) => editor.chain().focus().toggleBold().run(), 25 | icon: BoldIcon, 26 | }, 27 | { 28 | name: "italic", 29 | isActive: (editor) => editor.isActive("italic"), 30 | command: (editor) => editor.chain().focus().toggleItalic().run(), 31 | icon: ItalicIcon, 32 | }, 33 | { 34 | name: "underline", 35 | isActive: (editor) => editor.isActive("underline"), 36 | command: (editor) => editor.chain().focus().toggleUnderline().run(), 37 | icon: UnderlineIcon, 38 | }, 39 | { 40 | name: "strike", 41 | isActive: (editor) => editor.isActive("strike"), 42 | command: (editor) => editor.chain().focus().toggleStrike().run(), 43 | icon: StrikethroughIcon, 44 | }, 45 | { 46 | name: "code", 47 | isActive: (editor) => editor.isActive("code"), 48 | command: (editor) => editor.chain().focus().toggleCode().run(), 49 | icon: CodeIcon, 50 | }, 51 | ]; 52 | return ( 53 |
54 | {items.map((item) => ( 55 | { 58 | item.command(editor); 59 | }} 60 | > 61 | 68 | 69 | ))} 70 |
71 | ); 72 | }; 73 | -------------------------------------------------------------------------------- /apps/frontend/src/features/editor/ui/Editor/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/shared/lib"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | }, 34 | ); 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean; 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button"; 45 | return ( 46 | 51 | ); 52 | }, 53 | ); 54 | Button.displayName = "Button"; 55 | 56 | export { Button, buttonVariants }; 57 | -------------------------------------------------------------------------------- /apps/frontend/src/features/editor/ui/Editor/ui/icons/crazy-spinner.tsx: -------------------------------------------------------------------------------- 1 | const CrazySpinner = () => { 2 | return ( 3 |
4 |
5 |
6 |
7 |
8 | ); 9 | }; 10 | 11 | export default CrazySpinner; 12 | -------------------------------------------------------------------------------- /apps/frontend/src/features/editor/ui/Editor/ui/icons/index.tsx: -------------------------------------------------------------------------------- 1 | export { default as FontDefault } from "./font-default"; 2 | export { default as FontSerif } from "./font-serif"; 3 | export { default as FontMono } from "./font-mono"; 4 | -------------------------------------------------------------------------------- /apps/frontend/src/features/editor/ui/Editor/ui/icons/loading-circle.tsx: -------------------------------------------------------------------------------- 1 | export default function LoadingCircle({ dimensions }: { dimensions?: string }) { 2 | return ( 3 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /apps/frontend/src/features/editor/ui/Editor/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as PopoverPrimitive from "@radix-ui/react-popover"; 5 | 6 | import { cn } from "@/shared/lib"; 7 | 8 | const Popover = PopoverPrimitive.Root; 9 | 10 | const PopoverTrigger = PopoverPrimitive.Trigger; 11 | 12 | const PopoverContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 16 | 17 | 27 | 28 | )); 29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName; 30 | 31 | export { Popover, PopoverTrigger, PopoverContent }; 32 | -------------------------------------------------------------------------------- /apps/frontend/src/features/editor/ui/Editor/ui/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; 5 | 6 | import { cn } from "@/shared/lib"; 7 | 8 | const ScrollArea = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, children, ...props }, ref) => ( 12 | 17 | 18 | {children} 19 | 20 | 21 | 22 | 23 | )); 24 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; 25 | 26 | const ScrollBar = React.forwardRef< 27 | React.ElementRef, 28 | React.ComponentPropsWithoutRef 29 | >(({ className, orientation = "vertical", ...props }, ref) => ( 30 | 43 | 44 | 45 | )); 46 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; 47 | 48 | export { ScrollArea, ScrollBar }; 49 | -------------------------------------------------------------------------------- /apps/frontend/src/features/editor/ui/Editor/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator"; 5 | 6 | import { cn } from "@/shared/lib"; 7 | 8 | const Separator = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >( 12 | ( 13 | { className, orientation = "horizontal", decorative = true, ...props }, 14 | ref, 15 | ) => ( 16 | 27 | ), 28 | ); 29 | Separator.displayName = SeparatorPrimitive.Root.displayName; 30 | 31 | export { Separator }; 32 | -------------------------------------------------------------------------------- /apps/frontend/src/features/editor/ui/EditorActionPanel/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Maximize2, 3 | Minimize2, 4 | PanelLeftClose, 5 | PanelRightClose, 6 | } from "lucide-react"; 7 | 8 | import { useEditorStore } from "../../model/editorStore"; 9 | import SaveStatus from "../SaveStatus"; 10 | import { cn } from "@/shared/lib"; 11 | 12 | interface EditorActionPanelProps { 13 | saveStatus: "saved" | "unsaved"; 14 | } 15 | 16 | export function EditorActionPanel({ saveStatus }: EditorActionPanelProps) { 17 | const { isPanelOpen, togglePanel, isMaximized, toggleMaximized } = 18 | useEditorStore(); 19 | 20 | return ( 21 |
22 |
28 |
29 | 43 | 53 |
54 | 55 |
56 |
57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /apps/frontend/src/features/editor/ui/EditorTitle/index.tsx: -------------------------------------------------------------------------------- 1 | import Picker from "@emoji-mart/react"; 2 | 3 | import { useEditorTitle } from "../../model/useEditorTitle"; 4 | import { Emoji } from "@/shared/ui"; 5 | import { cn } from "@/shared/lib"; 6 | 7 | interface EditorTitleProps { 8 | currentPage: number; 9 | } 10 | 11 | interface Emoji { 12 | id: string; 13 | keywords: string[]; 14 | name: string; 15 | native: string; 16 | shortcodes: string; 17 | unified: string; 18 | } 19 | 20 | export function EditorTitle({ currentPage }: EditorTitleProps) { 21 | const { 22 | emoji, 23 | title, 24 | isEmojiPickerOpen, 25 | handleTitleEmojiClick, 26 | handleRemoveIconClick, 27 | handleEmojiClick, 28 | handleEmojiOutsideClick, 29 | handleTitleChange, 30 | } = useEditorTitle(currentPage); 31 | 32 | return ( 33 |
34 |
35 |
36 | 44 |
47 | 53 | 59 |
60 |
61 |
62 | 68 |
69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /apps/frontend/src/features/editor/ui/SaveStatus/index.tsx: -------------------------------------------------------------------------------- 1 | export interface SaveStatusProps { 2 | saveStatus: "saved" | "unsaved"; 3 | } 4 | 5 | export default function SaveStatus({ saveStatus }: SaveStatusProps) { 6 | return ( 7 |
8 | {saveStatus} 9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /apps/frontend/src/features/pageSidebar/index.ts: -------------------------------------------------------------------------------- 1 | export { LogoBtn } from "./ui/LogoBtn"; 2 | export { NoteList } from "./ui/NoteList"; 3 | export { Tools } from "./ui/Tools"; 4 | export { WorkspaceNav } from "./ui/WorkspaceNav"; 5 | -------------------------------------------------------------------------------- /apps/frontend/src/features/pageSidebar/model/useNoteList.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | import { Node, NoteNodeData } from "@/entities/node"; 4 | import { useDeletePage, usePageStore } from "@/entities/page"; 5 | import useConnectionStore from "@/shared/model/useConnectionStore"; 6 | 7 | export const useNoteList = () => { 8 | const { setCurrentPage } = usePageStore(); 9 | 10 | const [pages, setPages] = useState(); 11 | const { canvas } = useConnectionStore(); 12 | 13 | // TODO: 최적화 필요 14 | useEffect(() => { 15 | if (!canvas.provider) return; 16 | const nodesMap = canvas.provider.doc.getMap("nodes"); 17 | 18 | nodesMap.observe(() => { 19 | const yNodes = Array.from(nodesMap.values()) as Node[]; 20 | const data = yNodes.map((yNode) => yNode.data) as NoteNodeData[]; 21 | setPages(data); 22 | }); 23 | }, [canvas.provider]); 24 | 25 | const [noteIdToDelete, setNoteIdToDelete] = useState(null); 26 | const [isModalOpen, setIsModalOpen] = useState(false); 27 | 28 | const deleteMutation = useDeletePage(); 29 | 30 | const handleNoteClick = (id: number) => { 31 | setCurrentPage(id); 32 | }; 33 | 34 | const openModal = (noteId: number) => { 35 | setNoteIdToDelete(noteId); 36 | setIsModalOpen(true); 37 | }; 38 | 39 | const onConfirm = () => { 40 | if (noteIdToDelete === null) { 41 | return; 42 | } 43 | 44 | if (!canvas.provider) return; 45 | 46 | const nodesMap = canvas.provider.doc.getMap("nodes"); 47 | nodesMap.delete(noteIdToDelete.toString()); 48 | deleteMutation.mutate({ id: noteIdToDelete }); 49 | 50 | setIsModalOpen(false); 51 | setCurrentPage(null); 52 | }; 53 | 54 | const onCloseModal = () => { 55 | setIsModalOpen(false); 56 | }; 57 | 58 | return { 59 | pages, 60 | isModalOpen, 61 | handleNoteClick, 62 | openModal, 63 | onConfirm, 64 | onCloseModal, 65 | }; 66 | }; 67 | -------------------------------------------------------------------------------- /apps/frontend/src/features/pageSidebar/ui/LogoBtn/index.tsx: -------------------------------------------------------------------------------- 1 | import logo from "/icons/logo.png?url"; 2 | 3 | interface LogoBtnProps { 4 | onClick?: () => void; 5 | } 6 | 7 | export function LogoBtn({ onClick }: LogoBtnProps) { 8 | return ( 9 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /apps/frontend/src/features/pageSidebar/ui/NoteList/index.tsx: -------------------------------------------------------------------------------- 1 | import { Trash2 } from "lucide-react"; 2 | 3 | import { RemoveNoteModal } from "../RemoveNoteModal"; 4 | import { useNoteList } from "../../model/useNoteList"; 5 | import { cn } from "@/shared/lib"; 6 | import { Button, Emoji } from "@/shared/ui"; 7 | 8 | interface NoteListProps { 9 | className?: string; 10 | } 11 | 12 | export function NoteList({ className }: NoteListProps) { 13 | const { 14 | pages, 15 | isModalOpen, 16 | handleNoteClick, 17 | openModal, 18 | onConfirm, 19 | onCloseModal, 20 | } = useNoteList(); 21 | 22 | if (!pages) { 23 | return
로딩중
; 24 | } 25 | 26 | return ( 27 |
28 | 33 | {pages.map(({ id, title, emoji }) => ( 34 | 51 | ))} 52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /apps/frontend/src/features/pageSidebar/ui/RemoveNoteModal/index.tsx: -------------------------------------------------------------------------------- 1 | import WarningIcon from "/icons/warning-icon.png"; 2 | import { Button, Dialog } from "@/shared/ui"; 3 | 4 | type RemoveNoteModalProps = { 5 | isOpen: boolean; 6 | onConfirm: () => void; 7 | onCloseModal: () => void; 8 | }; 9 | 10 | export function RemoveNoteModal({ 11 | isOpen, 12 | onConfirm, 13 | onCloseModal, 14 | }: RemoveNoteModalProps) { 15 | return ( 16 | 17 |
18 |
19 | warning 20 | 페이지 삭제 21 | 정말 삭제하시겠습니까? 22 |
23 |
24 | 30 | 36 |
37 |
38 | 39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /apps/frontend/src/features/pageSidebar/ui/WorkspaceNav/index.tsx: -------------------------------------------------------------------------------- 1 | interface WorkspaceNavProps { 2 | title: string; 3 | } 4 | 5 | export function WorkspaceNav({ title }: WorkspaceNavProps) { 6 | return ( 7 |
8 |

{title}

9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /apps/frontend/src/features/workspace/api/workspaceApi.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CreateWorkSpaceResponse, 3 | CreateWorkSpaceResquest, 4 | GetCurrentUserWorkspaceResponse, 5 | GetUserWorkspaceResponse, 6 | RemoveWorkSpaceResponse, 7 | } from "../model/workspaceTypes"; 8 | import { Delete, Get, Post } from "@/shared/api"; 9 | 10 | const BASE_URL = "/api/workspace"; 11 | 12 | export const createWorkspace = async (payload: CreateWorkSpaceResquest) => { 13 | const res = await Post( 14 | BASE_URL, 15 | payload, 16 | ); 17 | return res.data; 18 | }; 19 | 20 | export const removeWorkspace = async (workspaceId: string) => { 21 | const url = `${BASE_URL}/${workspaceId}`; 22 | 23 | const res = await Delete(url); 24 | return res.data; 25 | }; 26 | 27 | // TODO: /entities/user vs workspace 위치 고민해봐야할듯? 28 | export const getUserWorkspaces = async () => { 29 | const url = `${BASE_URL}/user`; 30 | 31 | const res = await Get(url); 32 | return res.data; 33 | }; 34 | 35 | export const getCurrentWorkspace = async ( 36 | workspaceId: string, 37 | userId: string, 38 | ) => { 39 | const url = `${BASE_URL}/${workspaceId}/${userId}`; 40 | 41 | // Response type 바꾸기 42 | const res = await Get(url); 43 | return res.data; 44 | }; 45 | -------------------------------------------------------------------------------- /apps/frontend/src/features/workspace/api/workspaceStatusApi.ts: -------------------------------------------------------------------------------- 1 | import { SetWorkspaceStatusResponse } from "../model/workspaceInviteTypes"; 2 | import { Patch } from "@/shared/api"; 3 | 4 | export const setWorkspaceStatusToPrivate = async (id: string) => { 5 | const url = `/api/workspace/${id}/private`; 6 | await Patch(url); 7 | }; 8 | 9 | export const setWorkspaceStatusToPublic = async (id: string) => { 10 | const url = `/api/workspace/${id}/public`; 11 | await Patch(url); 12 | }; 13 | -------------------------------------------------------------------------------- /apps/frontend/src/features/workspace/api/worskspaceInviteApi.ts: -------------------------------------------------------------------------------- 1 | import { 2 | WorkspaceInviteLinkRequest, 3 | WorkspaceInviteLinkResponse, 4 | ValidateWorkspaceLinkResponse, 5 | } from "@/features/workspace/model/workspaceInviteTypes"; 6 | import { Get, Post } from "@/shared/api"; 7 | 8 | export const createWorkspaceInviteLink = async (id: string) => { 9 | const url = `/api/workspace/${id}/invite`; 10 | 11 | const res = await Post< 12 | WorkspaceInviteLinkResponse, 13 | WorkspaceInviteLinkRequest 14 | >(url, { id }); 15 | 16 | return res.data.inviteUrl; 17 | }; 18 | 19 | export const validateWorkspaceInviteLink = async (token: string) => { 20 | const url = `/api/workspace/join?token=${token}`; 21 | const res = await Get(url); 22 | return res.data; 23 | }; 24 | -------------------------------------------------------------------------------- /apps/frontend/src/features/workspace/index.ts: -------------------------------------------------------------------------------- 1 | export { useUserWorkspace, useCurrentWorkspace } from "./model/workspaceQuries"; 2 | export { useProtectedWorkspace } from "./model/useProtectedWorkspace"; 3 | export { useValidateWorkspaceInviteLink } from "./model/workspaceMutations"; 4 | 5 | export { ShareTool } from "./ui/ShareTool"; 6 | export { WorkspaceAddButton } from "./ui/WorkspaceAddButton"; 7 | export { WorkspaceForm } from "./ui/WorkspaceForm"; 8 | export { WorkspaceList } from "./ui/WorkspaceList"; 9 | -------------------------------------------------------------------------------- /apps/frontend/src/features/workspace/model/inviteLinkStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | type InviteLinkStore = { 4 | inviteLink: string | null; 5 | setInviteLink: (link: string) => void; 6 | }; 7 | 8 | export const useInviteLinkStore = create((set) => ({ 9 | inviteLink: null, 10 | setInviteLink: (link) => set({ inviteLink: link }), 11 | })); 12 | -------------------------------------------------------------------------------- /apps/frontend/src/features/workspace/model/useProtectedWorkspace.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useNavigate } from "@tanstack/react-router"; 3 | 4 | import { useCurrentWorkspace } from "../model/workspaceQuries"; 5 | 6 | export const useProtectedWorkspace = () => { 7 | const navigate = useNavigate(); 8 | const { 9 | data: workspaceData, 10 | isLoading: isWorkspaceLoading, 11 | error, 12 | } = useCurrentWorkspace(); 13 | 14 | useEffect(() => { 15 | if (!isWorkspaceLoading && (error || !workspaceData)) { 16 | navigate({ to: "/" }); 17 | } 18 | }, [isWorkspaceLoading, workspaceData, error, navigate]); 19 | 20 | return { 21 | isLoading: isWorkspaceLoading, 22 | workspaceData, 23 | }; 24 | }; 25 | -------------------------------------------------------------------------------- /apps/frontend/src/features/workspace/model/useWorkspaceStatus.ts: -------------------------------------------------------------------------------- 1 | import { useUserWorkspace } from "./workspaceQuries"; 2 | import { useWorkspace } from "@/shared/lib"; 3 | 4 | export const useWorkspaceStatus = () => { 5 | const { data } = useUserWorkspace(); 6 | const currentWorkspaceId = useWorkspace(); 7 | 8 | const workspaces = data?.workspaces; 9 | return workspaces?.find( 10 | (workspace) => workspace.workspaceId === currentWorkspaceId, 11 | )?.visibility; 12 | }; 13 | -------------------------------------------------------------------------------- /apps/frontend/src/features/workspace/model/workspaceInviteTypes.ts: -------------------------------------------------------------------------------- 1 | export interface WorkspaceInviteLinkRequest { 2 | id: string; 3 | } 4 | 5 | export interface WorkspaceInviteLinkResponse { 6 | message: string; 7 | inviteUrl: string; 8 | } 9 | 10 | export interface ValidateWorkspaceLinkResponse { 11 | message: string; 12 | } 13 | 14 | export interface SetWorkspaceStatusResponse { 15 | message: string; 16 | } 17 | -------------------------------------------------------------------------------- /apps/frontend/src/features/workspace/model/workspaceQuries.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | 3 | import { getUserWorkspaces, getCurrentWorkspace } from "../api/workspaceApi"; 4 | import { useGetUser } from "@/features/auth"; 5 | import { useWorkspace } from "@/shared/lib/useWorkspace"; 6 | 7 | export const useUserWorkspace = () => { 8 | return useQuery({ 9 | queryKey: ["userWorkspace"], 10 | queryFn: getUserWorkspaces, 11 | }); 12 | }; 13 | 14 | export const useCurrentWorkspace = () => { 15 | const workspaceId = useWorkspace(); 16 | const { data: user, isError } = useGetUser(); 17 | 18 | const snowflakeId = isError ? "null" : (user?.snowflakeId ?? "null"); 19 | 20 | return useQuery({ 21 | queryKey: ["currentWorkspace", workspaceId, snowflakeId], 22 | queryFn: () => getCurrentWorkspace(workspaceId, snowflakeId), 23 | enabled: Boolean(workspaceId), 24 | retry: false, 25 | refetchOnWindowFocus: false, 26 | }); 27 | }; 28 | -------------------------------------------------------------------------------- /apps/frontend/src/features/workspace/model/workspaceTypes.ts: -------------------------------------------------------------------------------- 1 | export interface Workspace { 2 | workspaceId: string; 3 | title: string; 4 | description: string; 5 | thumbnailUrl: string; 6 | role: "owner" | "guest"; 7 | visibility: "private" | "public"; 8 | } 9 | 10 | export interface CreateWorkSpaceResquest { 11 | title: string; 12 | description?: string; 13 | visibility?: "private" | "public"; 14 | thumbnailUrl?: string; 15 | } 16 | 17 | export interface CreateWorkSpaceResponse { 18 | message: string; 19 | workspaceId: string; 20 | } 21 | 22 | export interface RemoveWorkSpaceResponse { 23 | message: string; 24 | } 25 | 26 | export interface GetUserWorkspaceResponse { 27 | message: string; 28 | workspaces: Workspace[]; 29 | } 30 | 31 | export interface GetCurrentUserWorkspaceResponse { 32 | message: string; 33 | workspace: Workspace; 34 | } 35 | -------------------------------------------------------------------------------- /apps/frontend/src/features/workspace/ui/ShareTool/ShareButton.tsx: -------------------------------------------------------------------------------- 1 | export function Sharebutton() { 2 | return ( 3 |
4 | 10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /apps/frontend/src/features/workspace/ui/ShareTool/index.tsx: -------------------------------------------------------------------------------- 1 | import { Sharebutton } from "./ShareButton"; 2 | import { SharePanel } from "./SharePanel"; 3 | import { Popover } from "@/shared/ui"; 4 | 5 | export function ShareTool() { 6 | return ( 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /apps/frontend/src/features/workspace/ui/WorkspaceAddButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { CirclePlus } from "lucide-react"; 2 | 3 | import { Button } from "@/shared/ui"; 4 | 5 | interface WorkspaceAddButtonProps { 6 | onClick: () => void; 7 | } 8 | 9 | export function WorkspaceAddButton({ onClick }: WorkspaceAddButtonProps) { 10 | return ( 11 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /apps/frontend/src/features/workspace/ui/WorkspaceList/WorkspaceRemoveModal/index.tsx: -------------------------------------------------------------------------------- 1 | import WarningIcon from "/icons/warning-icon.png"; 2 | import { Button, Dialog } from "@/shared/ui"; 3 | 4 | type WorkspaceRemoveModalProps = { 5 | isOpen: boolean; 6 | onConfirm: () => void; 7 | onCloseModal: () => void; 8 | }; 9 | 10 | // TODO: RemoveModal도 리팩토링해도 될듯? 11 | export function WorkspaceRemoveModal({ 12 | isOpen, 13 | onConfirm, 14 | onCloseModal, 15 | }: WorkspaceRemoveModalProps) { 16 | return ( 17 | 18 |
19 |
20 | warning 21 | 워크스페이스 삭제 22 | 정말 삭제하시겠습니까? 23 |
24 |
25 | 31 | 37 |
38 |
39 | 40 |
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /apps/frontend/src/shared/api/axios.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosRequestConfig, AxiosResponse } from "axios"; 2 | 3 | const axiosInstance = axios.create({ 4 | baseURL: import.meta.env.VITE_API_URL, 5 | }); 6 | 7 | export const Get = async ( 8 | url: string, 9 | config?: AxiosRequestConfig, 10 | ): Promise> => { 11 | const response = await axiosInstance.get(url, config); 12 | return response; 13 | }; 14 | 15 | export const Post = async ( 16 | url: string, 17 | data?: D, 18 | config?: AxiosRequestConfig, 19 | ): Promise> => { 20 | const response = await axiosInstance.post(url, data, config); 21 | return response; 22 | }; 23 | 24 | export const Delete = async ( 25 | url: string, 26 | config?: AxiosRequestConfig, 27 | ): Promise> => { 28 | const response = await axiosInstance.delete(url, config); 29 | return response; 30 | }; 31 | 32 | export const Patch = async ( 33 | url: string, 34 | data?: D, 35 | config?: AxiosRequestConfig, 36 | ): Promise> => { 37 | const response = await axiosInstance.patch(url, data, config); 38 | return response; 39 | }; 40 | 41 | export const Put = async ( 42 | url: string, 43 | data?: D, 44 | config?: AxiosRequestConfig, 45 | ): Promise> => { 46 | const response = await axiosInstance.put(url, data, config); 47 | return response; 48 | }; 49 | -------------------------------------------------------------------------------- /apps/frontend/src/shared/api/index.ts: -------------------------------------------------------------------------------- 1 | export { Get, Post, Delete, Patch, Put } from "./axios"; 2 | export { createSocketIOProvider } from "./socketProvider"; 3 | -------------------------------------------------------------------------------- /apps/frontend/src/shared/api/socketProvider.ts: -------------------------------------------------------------------------------- 1 | import { SocketIOProvider } from "y-socket.io"; 2 | import * as Y from "yjs"; 3 | 4 | export const createSocketIOProvider = (roomname: string, ydoc: Y.Doc) => { 5 | return new SocketIOProvider( 6 | import.meta.env.VITE_WS_URL, 7 | roomname, 8 | ydoc, 9 | { 10 | autoConnect: true, 11 | disableBc: false, 12 | }, 13 | { 14 | reconnectionDelayMax: 10000, 15 | timeout: 5000, 16 | transports: ["websocket", "polling"], 17 | }, 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /apps/frontend/src/shared/lib/index.ts: -------------------------------------------------------------------------------- 1 | export { useWorkspace } from "./useWorkspace"; 2 | export { cn, getRandomColor, getRandomHexString } from "./utils"; 3 | -------------------------------------------------------------------------------- /apps/frontend/src/shared/lib/useWorkspace.ts: -------------------------------------------------------------------------------- 1 | import { useMatches } from "@tanstack/react-router"; 2 | 3 | export function useWorkspace(): string { 4 | const matches = useMatches(); 5 | const workspaceMatch = matches.find( 6 | (match) => match.routeId === "/workspace/$workspaceId", 7 | ); 8 | const workspaceId = workspaceMatch?.params.workspaceId ?? "main"; 9 | return workspaceId; 10 | } 11 | -------------------------------------------------------------------------------- /apps/frontend/src/shared/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | import diff from "fast-diff"; 4 | 5 | export function cn(...inputs: ClassValue[]) { 6 | return twMerge(clsx(inputs)); 7 | } 8 | 9 | export function getRandomColor() { 10 | const COLORS = [ 11 | "#7d7b94", 12 | "#41c76d", 13 | "#f86e7e", 14 | "#f6b8b8", 15 | "#f7d353", 16 | "#3b5bf7", 17 | "#59cbf7", 18 | ] as const; 19 | 20 | return COLORS[Math.floor(Math.random() * COLORS.length)]; 21 | } 22 | 23 | export function getRandomHexString(length = 10) { 24 | return [...Array(length)] 25 | .map(() => Math.floor(Math.random() * 16).toString(16)) 26 | .join(""); 27 | } 28 | 29 | type DiffResult = [number, string][]; // 각 요소는 [연산자, 값] 형태 30 | 31 | export function diffToDelta(diffResult: DiffResult) { 32 | return diffResult.map(([op, value]) => 33 | op === diff.INSERT 34 | ? { insert: value } 35 | : op === diff.EQUAL 36 | ? { retain: value.length } 37 | : op === diff.DELETE 38 | ? { delete: value.length } 39 | : null, 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /apps/frontend/src/shared/model/index.ts: -------------------------------------------------------------------------------- 1 | export { getPosition } from "./getPopoverPosition"; 2 | export { 3 | usePopover, 4 | PopoverContext, 5 | type Alignment, 6 | type Offset, 7 | type Placement, 8 | type PopoverContextType, 9 | } from "./usePopover"; 10 | export { useYText } from "./useYText"; 11 | export { initializeYText } from "./yjs"; 12 | -------------------------------------------------------------------------------- /apps/frontend/src/shared/model/useConnectionStatus.ts: -------------------------------------------------------------------------------- 1 | import useConnectionStore, { 2 | type ConnectionStatus, 3 | } from "./useConnectionStore"; 4 | import { usePageStore } from "@/entities/page"; 5 | 6 | export const useConnectionStatus = (): ConnectionStatus => { 7 | const { canvas, editor, user } = useConnectionStore(); 8 | const { currentPage } = usePageStore(); 9 | 10 | if ( 11 | (currentPage && 12 | canvas.connectionStatus === "connected" && 13 | editor.connectionStatus === "connected" && 14 | user.connectionStatus === "connected") || 15 | (!currentPage && 16 | canvas.connectionStatus === "connected" && 17 | user.connectionStatus === "connected") 18 | ) { 19 | return "connected"; 20 | } 21 | 22 | if ( 23 | canvas.connectionStatus === "connecting" || 24 | editor.connectionStatus === "connecting" || 25 | user.connectionStatus === "connecting" 26 | ) { 27 | return "connecting"; 28 | } 29 | 30 | return "disconnected"; 31 | }; 32 | -------------------------------------------------------------------------------- /apps/frontend/src/shared/model/useConnectionStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { SocketIOProvider } from "y-socket.io"; 3 | 4 | export type ConnectionStatus = "disconnected" | "connecting" | "connected"; 5 | 6 | interface ConnectionState { 7 | canvas: { 8 | provider: SocketIOProvider | null; 9 | connectionStatus: ConnectionStatus; 10 | }; 11 | editor: { 12 | provider: SocketIOProvider | null; 13 | connectionStatus: ConnectionStatus; 14 | }; 15 | user: { 16 | provider: SocketIOProvider | null; 17 | connectionStatus: ConnectionStatus; 18 | }; 19 | } 20 | 21 | interface ConnectionActions { 22 | setProvider: ( 23 | type: keyof ConnectionState, 24 | provider: SocketIOProvider, 25 | ) => void; 26 | setConnectionStatus: ( 27 | type: keyof ConnectionState, 28 | status: ConnectionStatus, 29 | ) => void; 30 | } 31 | 32 | const useConnectionStore = create( 33 | (set) => ({ 34 | canvas: { 35 | provider: null, 36 | connectionStatus: "disconnected", 37 | }, 38 | editor: { 39 | provider: null, 40 | connectionStatus: "disconnected", 41 | }, 42 | user: { 43 | provider: null, 44 | connectionStatus: "disconnected", 45 | }, 46 | setProvider: (type, provider) => 47 | set((state) => ({ 48 | [type]: { 49 | ...state[type], 50 | provider, 51 | }, 52 | })), 53 | setConnectionStatus: (type, status) => 54 | set((state) => ({ 55 | [type]: { 56 | ...state[type], 57 | connectionStatus: status, 58 | }, 59 | })), 60 | }), 61 | ); 62 | 63 | export default useConnectionStore; 64 | -------------------------------------------------------------------------------- /apps/frontend/src/shared/model/usePopover.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from "react"; 2 | 3 | export type Placement = "top" | "right" | "bottom" | "left"; 4 | export type Alignment = "start" | "center" | "end"; 5 | 6 | export interface Offset { 7 | x: number; 8 | y: number; 9 | } 10 | 11 | export interface PopoverContextType { 12 | open: boolean; 13 | setOpen: (open: boolean) => void; 14 | triggerRef: React.RefObject; 15 | placement: Placement; 16 | offset: Offset; 17 | align: Alignment; 18 | close: () => void; 19 | } 20 | 21 | export const PopoverContext = createContext(null); 22 | 23 | export function usePopover() { 24 | const context = useContext(PopoverContext); 25 | if (!context) { 26 | throw new Error("Popover 컨텍스트를 찾을 수 없음."); 27 | } 28 | return context; 29 | } 30 | -------------------------------------------------------------------------------- /apps/frontend/src/shared/model/useYText.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import * as Y from "yjs"; 3 | import diff from "fast-diff"; 4 | 5 | import { diffToDelta } from "../lib/utils"; 6 | 7 | type ReturnTypes = [string, (textNew: string) => void]; 8 | 9 | export const useYText = ( 10 | ydoc: Y.Doc, 11 | key: string, 12 | currentPage: number, 13 | ): ReturnTypes => { 14 | const yText = ydoc.getMap(key).get(`${key}_${currentPage}`) as Y.Text; 15 | 16 | const initialText = yText !== undefined ? yText.toString() : ""; 17 | const [input, setInput] = useState(initialText); 18 | 19 | const setYText = (textNew: string) => { 20 | if (yText === undefined) return; 21 | const delta = diffToDelta(diff(input, textNew)); 22 | yText.applyDelta(delta); 23 | }; 24 | 25 | yText.observe(() => { 26 | if (yText === undefined) return; 27 | setInput(yText.toString()); 28 | }); 29 | 30 | return [input, setYText]; 31 | }; 32 | -------------------------------------------------------------------------------- /apps/frontend/src/shared/model/yjs.ts: -------------------------------------------------------------------------------- 1 | import * as Y from "yjs"; 2 | 3 | export const initializeYText = ( 4 | ymap: Y.Map, 5 | key: string, 6 | initialData: string, 7 | ) => { 8 | if (!ymap.get(key)) { 9 | const yText = new Y.Text(); 10 | yText.insert(0, initialData); 11 | 12 | ymap.set(key, yText); 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /apps/frontend/src/shared/types.d.ts: -------------------------------------------------------------------------------- 1 | import { type LucideIcon } from "lucide-react"; 2 | 3 | declare global { 4 | type SelectorItem = { 5 | name: string; 6 | icon: LucideIcon; 7 | command: ( 8 | editor: NonNullable["editor"]>, 9 | ) => void; 10 | isActive: ( 11 | editor: NonNullable["editor"]>, 12 | ) => boolean; 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /apps/frontend/src/shared/ui/ActiveUser/index.tsx: -------------------------------------------------------------------------------- 1 | import { User } from "@/entities/user"; 2 | import { cn } from "@/shared/lib"; 3 | 4 | interface ActiveUserProps { 5 | users: User[]; 6 | className?: string; 7 | } 8 | 9 | export function ActiveUser({ users, className }: ActiveUserProps) { 10 | const maxVisibleUsers = 10; 11 | const hasMoreUsers = users.length > maxVisibleUsers; 12 | const visibleUsers = users.slice(0, maxVisibleUsers); 13 | 14 | return ( 15 |
16 | {visibleUsers.map((user, index) => ( 17 |
26 |
30 | {user.clientId} 31 |
32 |
33 | ))} 34 | {hasMoreUsers && ( 35 |
36 | +{users.length - maxVisibleUsers} 37 |
38 | )} 39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /apps/frontend/src/shared/ui/BiggerCursor/index.tsx: -------------------------------------------------------------------------------- 1 | import { motion } from "framer-motion"; 2 | 3 | import { Coors } from "../Cursor"; 4 | 5 | interface CursorProps { 6 | coors: Coors; 7 | clientId: string; 8 | color?: string; 9 | } 10 | 11 | export function BiggerCursor({ 12 | coors, 13 | color = "#ffb8b9", 14 | clientId, 15 | }: CursorProps) { 16 | const { x, y } = coors; 17 | 18 | return ( 19 | 32 |
33 | 40 | 44 | 45 |
49 | {clientId} 50 |
51 |
52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /apps/frontend/src/shared/ui/Button/index.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/shared/lib"; 2 | 3 | interface ButtonProps { 4 | className?: string; 5 | onClick?: () => void; 6 | children?: React.ReactNode; 7 | } 8 | 9 | export function Button({ className, onClick, children }: ButtonProps) { 10 | return ( 11 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /apps/frontend/src/shared/ui/Cursor/index.tsx: -------------------------------------------------------------------------------- 1 | import { motion } from "framer-motion"; 2 | 3 | export interface Coors { 4 | x: number; 5 | y: number; 6 | } 7 | 8 | interface CursorProps { 9 | coors: Coors; 10 | clientId: string; 11 | color?: string; 12 | } 13 | 14 | export function Cursor({ coors, color = "#ffb8b9", clientId }: CursorProps) { 15 | const { x, y } = coors; 16 | 17 | return ( 18 | 31 |
32 | 38 | 44 | 45 |
49 | {clientId} 50 |
51 |
52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /apps/frontend/src/shared/ui/Dialog/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRef } from "react"; 2 | import { X } from "lucide-react"; 3 | 4 | interface DialogTitleProps { 5 | children: React.ReactNode; 6 | } 7 | 8 | function DialogTitle({ children }: DialogTitleProps) { 9 | return

{children}

; 10 | } 11 | 12 | interface DialogDescriptionProps { 13 | children: React.ReactNode; 14 | } 15 | 16 | function DialogDescription({ children }: DialogDescriptionProps) { 17 | return
{children}
; 18 | } 19 | 20 | interface DialogCloseButtonProps { 21 | onCloseModal: () => void; 22 | } 23 | 24 | function DialogCloseButton({ onCloseModal }: DialogCloseButtonProps) { 25 | return ( 26 | 32 | ); 33 | } 34 | 35 | interface DialogMainProps { 36 | isOpen: boolean; 37 | onCloseModal: () => void; 38 | children: React.ReactNode; 39 | } 40 | 41 | function DialogMain({ isOpen, onCloseModal, children }: DialogMainProps) { 42 | const dialogRef = useRef(null); 43 | 44 | const showModal = () => { 45 | dialogRef.current?.showModal(); 46 | }; 47 | 48 | const closeModal = () => { 49 | dialogRef.current?.close(); 50 | }; 51 | 52 | if (isOpen) { 53 | showModal(); 54 | } else { 55 | closeModal(); 56 | } 57 | 58 | return ( 59 | { 64 | onCloseModal(); 65 | closeModal(); 66 | }} 67 | > 68 |
e.stopPropagation()} 71 | > 72 | {children} 73 |
74 |
75 | ); 76 | } 77 | 78 | export const Dialog = Object.assign(DialogMain, { 79 | Title: DialogTitle, 80 | Description: DialogDescription, 81 | CloseButton: DialogCloseButton, 82 | }); 83 | -------------------------------------------------------------------------------- /apps/frontend/src/shared/ui/Divider/index.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/shared/lib"; 2 | 3 | interface DividerProps { 4 | className?: string; 5 | direction: "horizontal" | "vertical"; 6 | } 7 | 8 | export function Divider({ className, direction }: DividerProps) { 9 | return ( 10 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /apps/frontend/src/shared/ui/Emoji/index.tsx: -------------------------------------------------------------------------------- 1 | import { FileText } from "lucide-react"; 2 | import { Tailwindest } from "tailwindest"; 3 | 4 | import { cn } from "@/shared/lib"; 5 | 6 | interface EmojiProps { 7 | emoji: string | null; 8 | width?: Tailwindest["width"]; 9 | height?: Tailwindest["height"]; 10 | fontSize?: Tailwindest["fontSize"]; 11 | } 12 | 13 | export function Emoji({ emoji, width, height, fontSize }: EmojiProps) { 14 | if (!emoji) 15 | return ( 16 | 21 | ); 22 | 23 | return
{emoji}
; 24 | } 25 | -------------------------------------------------------------------------------- /apps/frontend/src/shared/ui/FormField/index.tsx: -------------------------------------------------------------------------------- 1 | interface FormFieldProps { 2 | label: string; 3 | input: React.ReactNode; 4 | } 5 | 6 | export function FormField({ label, input }: FormFieldProps) { 7 | return ( 8 |
9 | 10 | {input} 11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /apps/frontend/src/shared/ui/MousePointer/index.tsx: -------------------------------------------------------------------------------- 1 | interface MousePointerProps { 2 | fill?: string; 3 | className?: string; 4 | } 5 | 6 | export function MousePointer({ fill, className }: MousePointerProps) { 7 | return ( 8 | 16 | 17 | 22 | 23 | 24 | 25 | 31 | 32 | 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /apps/frontend/src/shared/ui/Popover/Content.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useLayoutEffect, useRef, useState } from "react"; 2 | 3 | import { getPosition, usePopover } from "@/shared/model"; 4 | import { cn } from "@/shared/lib"; 5 | 6 | interface ContentProps { 7 | children: React.ReactNode; 8 | className?: string; 9 | } 10 | 11 | export function Content({ children, className }: ContentProps) { 12 | const { open, setOpen, triggerRef, placement, offset, align } = usePopover(); 13 | const contentRef = useRef(null); 14 | const [position, setPosition] = useState({ top: 0, left: 0 }); 15 | 16 | useLayoutEffect(() => { 17 | if (open && triggerRef.current && contentRef.current) { 18 | const triggerRect = triggerRef.current.getBoundingClientRect(); 19 | const contentRect = contentRef.current.getBoundingClientRect(); 20 | const newPosition = getPosition( 21 | triggerRect, 22 | contentRect, 23 | placement, 24 | offset, 25 | align, 26 | ); 27 | setPosition(newPosition); 28 | } 29 | }, [open, placement, offset, align, triggerRef]); 30 | 31 | useEffect(() => { 32 | const handleClickOutside = (e: MouseEvent) => { 33 | if ( 34 | !contentRef.current?.contains(e.target as Node) && 35 | !triggerRef.current?.contains(e.target as Node) 36 | ) { 37 | setOpen(false); 38 | } 39 | }; 40 | 41 | const handleEscape = (e: KeyboardEvent) => { 42 | if (e.key === "Escape") setOpen(false); 43 | }; 44 | 45 | document.addEventListener("mousedown", handleClickOutside); 46 | document.addEventListener("keydown", handleEscape); 47 | 48 | return () => { 49 | document.removeEventListener("mousedown", handleClickOutside); 50 | document.removeEventListener("keydown", handleEscape); 51 | }; 52 | }, [setOpen, triggerRef]); 53 | 54 | if (!open) return null; 55 | 56 | return ( 57 |
65 | {children} 66 |
67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /apps/frontend/src/shared/ui/Popover/Trigger.tsx: -------------------------------------------------------------------------------- 1 | import { usePopover } from "@/shared/model"; 2 | 3 | interface TriggerProps { 4 | children: React.ReactNode; 5 | } 6 | 7 | export function Trigger({ children }: TriggerProps) { 8 | const { open, setOpen, triggerRef } = usePopover(); 9 | 10 | return ( 11 |
setOpen(!open)}> 12 | {children} 13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /apps/frontend/src/shared/ui/Popover/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useState } from "react"; 2 | 3 | import { Content } from "./Content"; 4 | import { Trigger } from "./Trigger"; 5 | import { PopoverContext, Placement, Offset, Alignment } from "@/shared/model"; 6 | 7 | interface PopoverProps { 8 | children: React.ReactNode; 9 | placement?: Placement; 10 | offset?: Partial; 11 | align?: Alignment; 12 | } 13 | 14 | function Popover({ 15 | children, 16 | placement = "bottom", 17 | align = "center", 18 | offset = { x: 0, y: 0 }, 19 | }: PopoverProps) { 20 | const [open, setOpen] = useState(false); 21 | const triggerRef = useRef(null); 22 | 23 | const fullOffset: Offset = { 24 | x: offset.x ?? 0, 25 | y: offset.y ?? 0, 26 | }; 27 | 28 | const close = () => setOpen(false); 29 | 30 | return ( 31 | 42 | {children} 43 | 44 | ); 45 | } 46 | 47 | Popover.Trigger = Trigger; 48 | Popover.Content = Content; 49 | 50 | export { Popover }; 51 | -------------------------------------------------------------------------------- /apps/frontend/src/shared/ui/ScrollWrapper/index.tsx: -------------------------------------------------------------------------------- 1 | import { type Tailwindest } from "tailwindest"; 2 | 3 | import { cn } from "@/shared/lib"; 4 | 5 | type ScrollWrapperProps = { 6 | width?: Tailwindest["width"]; 7 | height?: Tailwindest["height"]; 8 | children: React.ReactNode; 9 | className?: string; 10 | }; 11 | 12 | export function ScrollWrapper({ 13 | width, 14 | height, 15 | children, 16 | className, 17 | }: ScrollWrapperProps) { 18 | return ( 19 |
20 | {children} 21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /apps/frontend/src/shared/ui/SideWrapper/index.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/shared/lib"; 2 | 3 | const sideStyle: { readonly [key in Side]: string } = { 4 | left: "left-0", 5 | right: "right-0 ", 6 | top: "top-0 left-1/2 -translate-x-1/2", 7 | bottom: "bottom-0 left-1/2 -translate-x-1/2", 8 | }; 9 | 10 | type Side = "left" | "right" | "top" | "bottom"; 11 | 12 | type SideWrapperProps = { 13 | side: Side; 14 | children: React.ReactNode; 15 | className?: string; 16 | }; 17 | 18 | export function SideWrapper({ side, children, className }: SideWrapperProps) { 19 | return ( 20 |
21 | {children} 22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /apps/frontend/src/shared/ui/Switch/index.tsx: -------------------------------------------------------------------------------- 1 | interface SwitchProps { 2 | checked: boolean; 3 | onChange: (checked: boolean) => void; 4 | CheckedIcon: React.ComponentType<{ className?: string }>; 5 | UncheckedIcon: React.ComponentType<{ className?: string }>; 6 | disabled?: boolean; 7 | } 8 | 9 | export function Switch({ 10 | checked, 11 | onChange, 12 | CheckedIcon, 13 | UncheckedIcon, 14 | disabled, 15 | }: SwitchProps) { 16 | return ( 17 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /apps/frontend/src/shared/ui/index.ts: -------------------------------------------------------------------------------- 1 | export { ActiveUser } from "./ActiveUser"; 2 | export { BiggerCursor } from "./BiggerCursor"; 3 | export { Button } from "./Button"; 4 | export { Cursor } from "./Cursor"; 5 | export { Dialog } from "./Dialog"; 6 | export { Divider } from "./Divider"; 7 | export { Emoji } from "./Emoji"; 8 | export { FormField } from "./FormField"; 9 | export { MousePointer } from "./MousePointer"; 10 | export { Popover } from "./Popover"; 11 | export { ScrollWrapper } from "./ScrollWrapper"; 12 | export { SideWrapper } from "./SideWrapper"; 13 | export { Switch } from "./Switch"; 14 | -------------------------------------------------------------------------------- /apps/frontend/src/shared/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /apps/frontend/src/widgets/CanvasToolsView/index.ts: -------------------------------------------------------------------------------- 1 | export { CanvasToolsView } from "./ui"; 2 | -------------------------------------------------------------------------------- /apps/frontend/src/widgets/CanvasToolsView/ui/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | import { useUserStore } from "@/entities/user"; 4 | import { CursorButton, ProfilePanel } from "@/features/canvasTools"; 5 | import { ShareTool } from "@/features/workspace"; 6 | import { Popover } from "@/shared/ui"; 7 | 8 | export function CanvasToolsView() { 9 | const { currentUser } = useUserStore(); 10 | const [color, setColor] = useState(currentUser.color); 11 | const [clientId, setClientId] = useState(currentUser.clientId); 12 | 13 | useEffect(() => { 14 | setColor(currentUser.color); 15 | setClientId(currentUser.clientId); 16 | }, [currentUser]); 17 | 18 | return ( 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /apps/frontend/src/widgets/CanvasView/index.ts: -------------------------------------------------------------------------------- 1 | export { CanvasView } from "./ui"; 2 | -------------------------------------------------------------------------------- /apps/frontend/src/widgets/CanvasView/ui/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactFlowProvider } from "@xyflow/react"; 2 | 3 | import { Canvas } from "@/features/canvas"; 4 | 5 | export function CanvasView() { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /apps/frontend/src/widgets/EditorView/index.ts: -------------------------------------------------------------------------------- 1 | export { EditorView } from "./ui"; 2 | -------------------------------------------------------------------------------- /apps/frontend/src/widgets/EditorView/model/useEditorView.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useDebouncedCallback } from "use-debounce"; 3 | 4 | import { useUserStore } from "@/entities/user"; 5 | import { usePageStore } from "@/entities/page"; 6 | import { useEditorStore } from "@/features/editor"; 7 | import useConnectionStore from "@/shared/model/useConnectionStore"; 8 | import { useEdtorConnection } from "@/features/editor/model/useEditorConnection"; 9 | 10 | export const useEditorView = () => { 11 | const { currentPage } = usePageStore(); 12 | const { isPanelOpen, isMaximized, setIsPanelOpen } = useEditorStore(); 13 | const [saveStatus, setSaveStatus] = useState<"saved" | "unsaved">("saved"); 14 | useEdtorConnection(currentPage); 15 | const { editor } = useConnectionStore(); 16 | const { users } = useUserStore(); 17 | 18 | useEffect(() => { 19 | if (currentPage) return; 20 | setIsPanelOpen(false); 21 | }, [currentPage]); 22 | 23 | useEffect(() => { 24 | if (!currentPage) return; 25 | setIsPanelOpen(true); 26 | }, [currentPage]); 27 | 28 | const handleEditorUpdate = useDebouncedCallback(async () => { 29 | if (currentPage === null) { 30 | return; 31 | } 32 | 33 | setSaveStatus("unsaved"); 34 | }, 500); 35 | 36 | return { 37 | currentPage, 38 | isPanelOpen, 39 | isMaximized, 40 | ydoc: editor.provider?.doc, 41 | provider: editor.provider, 42 | saveStatus, 43 | handleEditorUpdate, 44 | users, 45 | }; 46 | }; 47 | -------------------------------------------------------------------------------- /apps/frontend/src/widgets/EditorView/ui/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEditorView } from "../model/useEditorView"; 2 | import { Editor, EditorActionPanel, EditorTitle } from "@/features/editor"; 3 | import { ActiveUser } from "@/shared/ui"; 4 | import { cn } from "@/shared/lib"; 5 | 6 | export function EditorView() { 7 | const { 8 | currentPage, 9 | isPanelOpen, 10 | isMaximized, 11 | provider, 12 | saveStatus, 13 | handleEditorUpdate, 14 | users, 15 | } = useEditorView(); 16 | 17 | if (currentPage === null) { 18 | return null; 19 | } 20 | 21 | if (!provider || !provider.doc) return null; 22 | 23 | return ( 24 |
31 | 32 |
38 | 39 | user.currentPageId === currentPage.toString(), 42 | )} 43 | /> 44 | 51 |
52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /apps/frontend/src/widgets/NodeToolsView/index.ts: -------------------------------------------------------------------------------- 1 | export { NodeToolsView } from "./ui"; 2 | -------------------------------------------------------------------------------- /apps/frontend/src/widgets/NodeToolsView/ui/index.tsx: -------------------------------------------------------------------------------- 1 | import { usePageStore } from "@/entities/page"; 2 | import { NodePanel } from "@/features/canvasTools/ui/NodePanel"; 3 | 4 | export function NodeToolsView() { 5 | const { currentPage } = usePageStore(); 6 | 7 | if (!currentPage) return null; 8 | 9 | return ( 10 |
11 | 12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /apps/frontend/src/widgets/PageSideBarView/index.ts: -------------------------------------------------------------------------------- 1 | export { PageSideBarView } from "./ui"; 2 | -------------------------------------------------------------------------------- /apps/frontend/src/widgets/PageSideBarView/ui/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | import { NoteList, Tools } from "@/features/pageSidebar"; 4 | import { TopNavView } from "@/widgets/TopNavView"; 5 | import { ScrollWrapper } from "@/shared/ui"; 6 | 7 | export function PageSideBarView() { 8 | const [isExpanded, setIsExpanded] = useState(false); 9 | 10 | const handleExpand = () => { 11 | setIsExpanded(!isExpanded); 12 | }; 13 | 14 | return ( 15 |
16 |
17 | 18 |
19 |
20 |
21 | 22 |
23 | 24 | 25 | 26 |
27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /apps/frontend/src/widgets/TopNavView/index.ts: -------------------------------------------------------------------------------- 1 | export { TopNavView } from "./ui"; 2 | -------------------------------------------------------------------------------- /apps/frontend/src/widgets/TopNavView/ui/index.tsx: -------------------------------------------------------------------------------- 1 | import { Menu, X } from "lucide-react"; 2 | 3 | import { WorkspaceNav } from "@/features/pageSidebar"; 4 | import { useCurrentWorkspace } from "@/features/workspace"; 5 | import { UserInfoView } from "@/widgets/UserInfoView"; 6 | import { Divider } from "@/shared/ui"; 7 | 8 | interface TopNavProps { 9 | onExpand: () => void; 10 | isExpanded: boolean; 11 | } 12 | export function TopNavView({ onExpand, isExpanded }: TopNavProps) { 13 | const { data } = useCurrentWorkspace(); 14 | 15 | const getWorkspaceTitle = () => { 16 | if (!data) return "로딩 중"; 17 | 18 | if (data.workspace.workspaceId === "main") return "공용 워크스페이스"; 19 | 20 | return data.workspace.title; 21 | }; 22 | 23 | return ( 24 |
25 |
26 | 27 | 28 | 29 |
30 |
31 | 38 |
39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /apps/frontend/src/widgets/UserInfoView/index.ts: -------------------------------------------------------------------------------- 1 | export { UserInfoView } from "./ui"; 2 | -------------------------------------------------------------------------------- /apps/frontend/src/widgets/UserInfoView/ui/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | import { UserProfile } from "@/entities/user"; 4 | import { LoginForm, Logout, useGetUser } from "@/features/auth"; 5 | import { LogoBtn } from "@/features/pageSidebar"; 6 | import { 7 | WorkspaceAddButton, 8 | WorkspaceForm, 9 | WorkspaceList, 10 | } from "@/features/workspace"; 11 | import { Divider, Popover } from "@/shared/ui"; 12 | 13 | export function UserInfoView() { 14 | const { data } = useGetUser(); 15 | const [isModalOpen, setIsModalOpen] = useState(false); 16 | 17 | const onOpenModal = () => { 18 | setIsModalOpen(true); 19 | }; 20 | 21 | const onCloseModal = () => { 22 | setIsModalOpen(false); 23 | }; 24 | 25 | return ( 26 |
27 | 28 | 29 | 30 | 31 | 32 | {data ? ( 33 |
34 | 35 | 36 | 37 | 41 | 42 |
43 | 44 | 45 |
46 |
47 | ) : ( 48 | 49 | )} 50 |
51 |
52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /apps/frontend/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "Bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true, 24 | "baseUrl": "src", 25 | "paths": { 26 | "@/*": ["*"], 27 | "@components/*": ["components/*"] 28 | } 29 | }, 30 | "include": ["src"] 31 | } 32 | -------------------------------------------------------------------------------- /apps/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ], 7 | "compilerOptions": { 8 | "baseUrl": "src", 9 | "paths": { 10 | "@/*": ["./*"] 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /apps/frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "Bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /apps/frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import tailwindcss from "tailwindcss"; 4 | import tsconfigPaths from "vite-tsconfig-paths"; 5 | import { TanStackRouterVite } from "@tanstack/router-plugin/vite"; 6 | 7 | // https://vite.dev/config/ 8 | export default defineConfig({ 9 | plugins: [ 10 | TanStackRouterVite({ 11 | routesDirectory: "./src/app/routes", 12 | generatedRouteTree: "./src/app/routeTree.gen.ts", 13 | }), 14 | react(), 15 | tsconfigPaths(), 16 | ], 17 | css: { 18 | postcss: { 19 | plugins: [tailwindcss()], 20 | }, 21 | }, 22 | server: { 23 | host: "0.0.0.0", 24 | port: 5173, 25 | watch: { 26 | usePolling: true, 27 | interval: 1000, 28 | }, 29 | hmr: { 30 | protocol: "wss", 31 | clientPort: 443, 32 | path: "hmr/", 33 | }, 34 | }, 35 | }); 36 | -------------------------------------------------------------------------------- /apps/websocket/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /apps/websocket/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "tabWidth": 2 5 | } 6 | -------------------------------------------------------------------------------- /apps/websocket/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 | -------------------------------------------------------------------------------- /apps/websocket/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | 4 | @Controller() 5 | export class AppController { 6 | constructor(private readonly appService: AppService) {} 7 | 8 | @Get('health') 9 | healthCheck() { 10 | return { 11 | status: 'ok', 12 | timestamp: new Date().toISOString(), 13 | }; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /apps/websocket/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import * as path from 'path'; 4 | import { YjsModule } from './yjs/yjs.module'; 5 | import { AppController } from './app.controller'; 6 | import { AppService } from './app.service'; 7 | 8 | @Module({ 9 | imports: [ 10 | ConfigModule.forRoot({ 11 | isGlobal: true, 12 | envFilePath: path.join(__dirname, '..', '.env'), // * nest 디렉터리 기준 13 | }), 14 | YjsModule, 15 | ], 16 | controllers: [AppController], 17 | providers: [AppService], 18 | }) 19 | export class AppModule {} 20 | -------------------------------------------------------------------------------- /apps/websocket/src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class AppService {} 5 | -------------------------------------------------------------------------------- /apps/websocket/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import { IoAdapter } from '@nestjs/platform-socket.io'; 4 | 5 | async function bootstrap() { 6 | const app = await NestFactory.create(AppModule); 7 | app.useWebSocketAdapter(new IoAdapter(app)); 8 | app.enableCors({ 9 | origin: 10 | process.env.NODE_ENV === 'production' 11 | ? ['https://octodocs.site', 'https://www.octodocs.site'] 12 | : process.env.origin, 13 | credentials: true, 14 | }); 15 | await app.listen(4242); 16 | } 17 | bootstrap(); 18 | -------------------------------------------------------------------------------- /apps/websocket/src/red-lock/red-lock.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, forwardRef } from '@nestjs/common'; 2 | import Redis from 'ioredis'; 3 | import Redlock from 'redlock'; 4 | import { RedisModule } from '../redis/redis.module'; 5 | const RED_LOCK_TOKEN = 'RED_LOCK'; 6 | const REDIS_CLIENT_TOKEN = 'REDIS_CLIENT'; 7 | 8 | @Module({ 9 | imports: [forwardRef(() => RedisModule)], 10 | providers: [ 11 | { 12 | provide: RED_LOCK_TOKEN, 13 | useFactory: (redisClient: Redis) => { 14 | return new Redlock([redisClient], { 15 | driftFactor: 0.01, 16 | retryCount: 10, 17 | retryDelay: 200, 18 | retryJitter: 200, 19 | automaticExtensionThreshold: 500, 20 | }); 21 | }, 22 | inject: [REDIS_CLIENT_TOKEN], 23 | }, 24 | ], 25 | exports: [RED_LOCK_TOKEN], 26 | }) 27 | export class RedLockModule {} 28 | -------------------------------------------------------------------------------- /apps/websocket/src/redis/redis.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, forwardRef } from '@nestjs/common'; 2 | import { ConfigModule, ConfigService } from '@nestjs/config'; 3 | import { RedisService } from './redis.service'; 4 | import Redis from 'ioredis'; 5 | import { RedLockModule } from '../red-lock/red-lock.module'; 6 | 7 | // 의존성 주입할 때 redis client를 식별할 토큰 8 | const REDIS_CLIENT_TOKEN = 'REDIS_CLIENT'; 9 | 10 | @Module({ 11 | imports: [ConfigModule, forwardRef(() => RedLockModule)], // ConfigModule 추가 12 | providers: [ 13 | RedisService, 14 | { 15 | provide: REDIS_CLIENT_TOKEN, 16 | inject: [ConfigService], // ConfigService 주입 17 | useFactory: (configService: ConfigService) => { 18 | return new Redis({ 19 | host: configService.get('REDIS_HOST'), 20 | port: configService.get('REDIS_PORT'), 21 | }); 22 | }, 23 | }, 24 | ], 25 | exports: [RedisService, REDIS_CLIENT_TOKEN], 26 | }) 27 | export class RedisModule {} 28 | -------------------------------------------------------------------------------- /apps/websocket/src/redis/redis.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Inject } from '@nestjs/common'; 3 | import Redis from 'ioredis'; 4 | import Redlock from 'redlock'; 5 | const REDIS_CLIENT_TOKEN = 'REDIS_CLIENT'; 6 | const RED_LOCK_TOKEN = 'RED_LOCK'; 7 | 8 | type RedisPage = { 9 | title?: string; 10 | content?: string; 11 | }; 12 | 13 | @Injectable() 14 | export class RedisService { 15 | constructor( 16 | @Inject(REDIS_CLIENT_TOKEN) private readonly redisClient: Redis, 17 | @Inject(RED_LOCK_TOKEN) private readonly redisLock: Redlock, 18 | ) {} 19 | 20 | async getAllKeys(pattern) { 21 | return await this.redisClient.keys(pattern); 22 | } 23 | 24 | createStream() { 25 | return this.redisClient.scanStream(); 26 | } 27 | 28 | async get(key: string) { 29 | const data = await this.redisClient.hgetall(key); 30 | return Object.fromEntries( 31 | Object.entries(data).map(([field, value]) => [field, value]), 32 | ) as RedisPage; 33 | } 34 | 35 | async set(key: string, value: object) { 36 | // 락을 획득할 때까지 기다린다. 37 | const lock = await this.redisLock.acquire([`user:${key}`], 1000); 38 | try { 39 | await this.redisClient.hset(key, Object.entries(value)); 40 | } finally { 41 | lock.release(); 42 | } 43 | } 44 | 45 | async setField(key: string, field: string, value: string) { 46 | // 락을 획득할 때까지 기다린다. 47 | const lock = await this.redisLock.acquire([`user:${key}`], 1000); 48 | try { 49 | return await this.redisClient.hset(key, field, value); 50 | } finally { 51 | lock.release(); 52 | } 53 | } 54 | 55 | async delete(key: string) { 56 | // 락을 획득할 때까지 기다린다. 57 | const lock = await this.redisLock.acquire([`user:${key}`], 1000); 58 | try { 59 | return await this.redisClient.del(key); 60 | } finally { 61 | lock.release(); 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /apps/websocket/src/yjs/types/edge.entity.ts: -------------------------------------------------------------------------------- 1 | import { Node } from './node.entity'; 2 | 3 | export class Edge { 4 | id: number; 5 | 6 | fromNode: Node; 7 | 8 | toNode: Node; 9 | 10 | workspace: unknown; 11 | 12 | // @Column({ nullable: true }) 13 | // type: string; 14 | 15 | // @Column({ nullable: true }) 16 | // color: string; 17 | } 18 | -------------------------------------------------------------------------------- /apps/websocket/src/yjs/types/node.entity.ts: -------------------------------------------------------------------------------- 1 | import { Page } from './page.entity'; 2 | import { Edge } from './edge.entity'; 3 | 4 | export class Node { 5 | id: number; 6 | 7 | x: number; 8 | 9 | y: number; 10 | 11 | color: string; 12 | 13 | page: Page; 14 | 15 | outgoingEdges: Edge[]; 16 | 17 | incomingEdges: Edge[]; 18 | 19 | workspace: unknown; 20 | } 21 | -------------------------------------------------------------------------------- /apps/websocket/src/yjs/types/page.entity.ts: -------------------------------------------------------------------------------- 1 | import { Node } from './node.entity'; 2 | 3 | export class Page { 4 | id: number; 5 | 6 | title: string; 7 | 8 | content: JSON; 9 | 10 | createdAt: Date; 11 | 12 | updatedAt: Date; 13 | 14 | version: number; 15 | 16 | emoji: string | null; 17 | 18 | node: Node; 19 | 20 | workspace: unknown; 21 | } 22 | -------------------------------------------------------------------------------- /apps/websocket/src/yjs/yjs.class.ts: -------------------------------------------------------------------------------- 1 | import * as Y from 'yjs'; 2 | 3 | // Y.Doc에는 name 컬럼이 없어서 생성했습니다. 4 | export class CustomDoc extends Y.Doc { 5 | name: string; 6 | 7 | constructor(name: string) { 8 | super(); 9 | this.name = name; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /apps/websocket/src/yjs/yjs.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { YjsService } from './yjs.service'; 3 | import { RedisModule } from '../redis/redis.module'; 4 | 5 | @Module({ 6 | imports: [RedisModule], 7 | providers: [YjsService], 8 | }) 9 | export class YjsModule {} 10 | -------------------------------------------------------------------------------- /apps/websocket/src/yjs/yjs.type.ts: -------------------------------------------------------------------------------- 1 | // yMap에 저장되는 Node 형태 2 | export type YMapNode = { 3 | id: string; // 노드 아이디 4 | type: string; // 노드의 유형 5 | data: { 6 | title: string; // 제목 7 | id: number; // 페이지 아이디 8 | emoji: string | null; 9 | }; 10 | position: { 11 | x: number; // X 좌표 12 | y: number; // Y 좌표 13 | }; 14 | color: string; // 색상 15 | selected: boolean; 16 | isHolding: boolean; 17 | }; 18 | 19 | // yMap에 저장되는 edge 형태 20 | export type YMapEdge = { 21 | id: string; // Edge 아이디 22 | source: string; // 출발 노드 아이디 23 | target: string; // 도착 노드 아이디 24 | sourceHandle: string; 25 | targetHandle: string; 26 | }; 27 | -------------------------------------------------------------------------------- /apps/websocket/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 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /compose.init.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | nginx: 5 | image: nginx:alpine 6 | restart: always 7 | ports: 8 | - "80:80" 9 | volumes: 10 | - ./services/nginx/conf.d/prod_nginx_init.conf:/etc/nginx/conf.d/default.conf 11 | - ./data/certbot/www:/var/www/certbot 12 | networks: 13 | - frontend 14 | 15 | certbot: 16 | image: certbot/certbot:latest 17 | volumes: 18 | - ./data/certbot/conf:/etc/letsencrypt 19 | - ./data/certbot/www:/var/www/certbot 20 | - ./data/certbot/log:/var/log/letsencrypt 21 | command: > 22 | certonly --webroot 23 | --webroot-path=/var/www/certbot 24 | --email hihj070914@icloud.com 25 | --agree-tos 26 | --no-eff-email 27 | -d octodocs.site 28 | -d www.octodocs.site 29 | 30 | networks: 31 | frontend: 32 | driver: bridge 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "octodocs", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "repository": "https://github.com/boostcampwm-2024/web15-OctoDocs.git", 6 | "author": "ez <105545215+ezcolin2@users.noreply.github.com>", 7 | "license": "MIT", 8 | "scripts": { 9 | "dev": "turbo run dev --parallel", 10 | "build": "turbo run build", 11 | "start": "node apps/backend/dist/main.js", 12 | "start:backend": "node apps/backend/dist/main.js", 13 | "start:websocket": "node apps/websocket/dist/main.js", 14 | "lint": "turbo run lint", 15 | "test": "turbo run test", 16 | "docker:dev": "docker compose -f compose.local.yml up", 17 | "docker:dev:down": "docker compose -f compose.local.yml down", 18 | "docker:dev:clean": "docker compose -v -f compose.local.yml down", 19 | "docker:dev:fclean": "docker compose -v -f compose.local.yml down --rmi all", 20 | "ssl:generate": "cd services/nginx/ssl && bash ./generate-cert.sh" 21 | }, 22 | "dependencies": { 23 | "turbo": "^2.3.0" 24 | }, 25 | "private": true, 26 | "workspaces": [ 27 | "apps/*" 28 | ], 29 | "packageManager": "yarn@1.22.22" 30 | } 31 | -------------------------------------------------------------------------------- /services/backend/Dockerfile.local: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine 2 | 3 | WORKDIR /app 4 | 5 | # 개발 의존성 설치를 위한 파일들 6 | COPY package.json yarn.lock ./ 7 | COPY turbo.json ./ 8 | COPY apps/backend/package.json ./apps/backend/ 9 | COPY apps/frontend/package.json ./apps/frontend/ 10 | 11 | # 개발 의존성 설치 (프로덕션 플래그 제거) 12 | RUN yarn install 13 | 14 | # 소스 코드는 볼륨으로 마운트할 예정이므로 COPY 불필요 15 | 16 | EXPOSE 3000 17 | 18 | # 개발 모드로 실행 19 | CMD ["yarn", "dev"] -------------------------------------------------------------------------------- /services/backend/Dockerfile.prod: -------------------------------------------------------------------------------- 1 | # 빌드 스테이지 2 | FROM node:20-alpine as builder 3 | 4 | WORKDIR /app 5 | 6 | # yarn 설정 추가 7 | RUN yarn config set network-timeout 300000 && \ 8 | yarn config set network-concurrency 1 9 | 10 | # 의존성 파일 복사 11 | COPY package.json yarn.lock ./ 12 | COPY turbo.json ./ 13 | COPY apps/backend/package.json ./apps/backend/ 14 | COPY apps/frontend/package.json ./apps/frontend/ 15 | 16 | # 의존성 설치 (재시도 옵션 추가) 17 | RUN yarn install --frozen-lockfile --network-timeout 300000 || \ 18 | yarn install --frozen-lockfile --network-timeout 300000 || \ 19 | yarn install --frozen-lockfile --network-timeout 300000 20 | 21 | # 소스 코드 복사 22 | COPY . . 23 | 24 | # 백엔드 빌드 25 | RUN yarn turbo run build --filter=backend 26 | 27 | # 실행 스테이지 28 | FROM node:20-alpine 29 | 30 | WORKDIR /app 31 | 32 | # wget 설치 33 | RUN apk add --no-cache wget 34 | 35 | # 프로덕션에 필요한 파일만 복사 36 | COPY --from=builder /app/package.json /app/yarn.lock ./ 37 | COPY --from=builder /app/apps/backend/package.json ./apps/backend/ 38 | COPY --from=builder /app/apps/backend/dist ./apps/backend/dist 39 | COPY --from=builder /app/node_modules ./node_modules 40 | COPY --from=builder /app/apps/backend/node_modules ./apps/backend/node_modules 41 | 42 | ENV NODE_ENV=production 43 | 44 | EXPOSE 3000 45 | 46 | CMD ["yarn", "start:backend"] 47 | -------------------------------------------------------------------------------- /services/nginx/Dockerfile.local: -------------------------------------------------------------------------------- 1 | FROM nginx:alpine 2 | 3 | # 필요한 설정 파일 복사 4 | COPY services/nginx/conf.d/default.conf /etc/nginx/conf.d/default.conf 5 | 6 | # SSL 설정 및 권한 조정 7 | RUN mkdir -p /etc/nginx/ssl && \ 8 | chown -R nginx:nginx /etc/nginx/ssl && \ 9 | chmod 700 /etc/nginx/ssl 10 | -------------------------------------------------------------------------------- /services/nginx/ssl/generate-cert.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 스크립트가 위치한 디렉토리로 이동 4 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 5 | cd "$SCRIPT_DIR" 6 | 7 | # OpenSSL 설정 파일 생성 8 | cat > openssl.conf << EOF 9 | [req] 10 | default_bits = 2048 11 | default_keyfile = localhost.key 12 | distinguished_name = req_distinguished_name 13 | req_extensions = req_ext 14 | x509_extensions = v3_ca 15 | prompt = no 16 | 17 | [req_distinguished_name] 18 | C = KR 19 | ST = Seoul 20 | L = Seoul 21 | O = OctoDocs 22 | OU = Development 23 | CN = localhost 24 | 25 | [req_ext] 26 | subjectAltName = @alt_names 27 | 28 | [v3_ca] 29 | subjectAltName = @alt_names 30 | 31 | [alt_names] 32 | DNS.1 = localhost 33 | DNS.2 = *.localhost 34 | DNS.3 = octodocs.local 35 | DNS.4 = *.octodocs.local 36 | IP.1 = 127.0.0.1 37 | EOF 38 | 39 | # 인증서 생성 40 | openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout localhost.key -out localhost.crt -config openssl.conf 41 | 42 | chmod 644 localhost.crt 43 | chmod 644 localhost.key 44 | 45 | # 설정 파일 정리 46 | rm openssl.conf 47 | 48 | echo "SSL certificate generated successfully!" 49 | echo "Certificate: $SCRIPT_DIR/localhost.crt" 50 | echo "Private key: $SCRIPT_DIR/localhost.key" -------------------------------------------------------------------------------- /services/websocket/Dockerfile.local: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine 2 | 3 | WORKDIR /app 4 | 5 | # Install dependencies 6 | COPY package.json yarn.lock ./ 7 | RUN yarn install 8 | 9 | # Copy source 10 | COPY . . 11 | 12 | # Install app dependencies 13 | WORKDIR /app/apps/websocket 14 | RUN yarn install 15 | 16 | EXPOSE 4242 17 | 18 | # Start the application 19 | CMD ["yarn", "dev"] -------------------------------------------------------------------------------- /services/websocket/Dockerfile.prod: -------------------------------------------------------------------------------- 1 | # 빌드 스테이지 2 | FROM node:20-alpine as builder 3 | 4 | WORKDIR /app 5 | 6 | # yarn 설정 추가 7 | RUN yarn config set network-timeout 300000 && \ 8 | yarn config set network-concurrency 1 9 | 10 | # 패키지 파일 복사 11 | COPY package.json yarn.lock ./ 12 | COPY apps/websocket/package.json ./apps/websocket/ 13 | COPY turbo.json ./ 14 | 15 | # 의존성 설치 (재시도 옵션 추가) 16 | RUN yarn install --frozen-lockfile --network-timeout 300000 || \ 17 | yarn install --frozen-lockfile --network-timeout 300000 || \ 18 | yarn install --frozen-lockfile --network-timeout 300000 19 | 20 | # 소스 코드 복사 21 | COPY . . 22 | 23 | # 빌드 24 | RUN yarn turbo run build --filter=websocket 25 | 26 | # 프로덕션 스테이지 27 | FROM node:20-alpine 28 | 29 | WORKDIR /app 30 | 31 | # 빌드된 파일과 필요한 의존성만 복사 32 | COPY --from=builder /app/package.json /app/yarn.lock ./ 33 | COPY --from=builder /app/apps/websocket/package.json ./apps/websocket/ 34 | COPY --from=builder /app/apps/websocket/dist ./apps/websocket/dist 35 | COPY --from=builder /app/node_modules ./node_modules 36 | COPY --from=builder /app/apps/websocket/node_modules ./apps/websocket/node_modules 37 | 38 | # 프로덕션 모드로 실행 39 | ENV NODE_ENV=production 40 | 41 | EXPOSE 4242 42 | 43 | CMD ["yarn", "start:websocket"] -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "tasks": { 4 | "dev": { 5 | "cache": false, 6 | "persistent": true 7 | }, 8 | "build": { 9 | "dependsOn": ["^build"], 10 | "outputs": ["dist/**"] 11 | }, 12 | "lint": {}, 13 | "test": {} 14 | } 15 | } 16 | --------------------------------------------------------------------------------