├── .husky ├── pre-commit └── commit-msg ├── .github ├── CODEOWNERS ├── workflows │ ├── reviewers.json │ ├── DEPLOY.yml │ ├── REVIEW_REQUEST_ALERT.yml │ └── FRONTEND_PR_CHECK.yml ├── ISSUE_TEMPLATE │ └── issue-template.md └── PULL_REQUEST_TEMPLATE.md ├── pnpm-workspace.yaml ├── packages ├── shared │ ├── package.json │ ├── tsconfig.json │ └── types │ │ └── index.ts ├── backend │ ├── .dockerignore │ ├── .prettierrc │ ├── src │ │ ├── common │ │ │ ├── constants │ │ │ │ ├── space.constants.ts │ │ │ │ ├── websocket.constants.ts │ │ │ │ └── error.message.constants.ts │ │ │ ├── filters │ │ │ │ └── all-exceptions.filter.ts │ │ │ ├── utils │ │ │ │ └── socket.util.ts │ │ │ └── config │ │ │ │ ├── mongo.config.ts │ │ │ │ └── typeorm.config.ts │ │ ├── note │ │ │ ├── dto │ │ │ │ └── note.dto.ts │ │ │ ├── note.module.ts │ │ │ ├── note.schema.ts │ │ │ ├── note.service.ts │ │ │ └── note.controller.ts │ │ ├── test │ │ │ ├── mock │ │ │ │ ├── mock.constants.ts │ │ │ │ ├── note.mock.data.ts │ │ │ │ └── space.mock.data.ts │ │ │ ├── types │ │ │ │ └── performance.types.ts │ │ │ ├── note.test.entity.ts │ │ │ ├── space.test.entity.ts │ │ │ ├── test.module.ts │ │ │ └── test.service.ts │ │ ├── space │ │ │ ├── dto │ │ │ │ ├── create.space.dto.ts │ │ │ │ └── update.space.dto.ts │ │ │ ├── space.module.ts │ │ │ ├── space.schema.ts │ │ │ ├── space.validation.service.ts │ │ │ ├── space.service.spec.ts │ │ │ ├── space.controller.spec.ts │ │ │ └── space.validation.service.spec.ts │ │ ├── collaborative │ │ │ ├── collaborative.type.ts │ │ │ ├── collaborative.module.ts │ │ │ └── collaborative.service.ts │ │ ├── yjs │ │ │ ├── yjs.module.ts │ │ │ └── yjs.gateway.spec.ts │ │ ├── app.module.ts │ │ ├── env.d.ts │ │ └── main.ts │ ├── tsconfig.build.json │ ├── nest-cli.json │ ├── test │ │ ├── jest-e2e.json │ │ └── app.e2e-spec.ts │ ├── tsconfig.json │ ├── .gitignore │ ├── Dockerfile │ └── package.json ├── frontend │ ├── .dockerignore │ ├── src │ │ ├── vite-env.d.ts │ │ ├── App.css │ │ ├── api │ │ │ ├── constants.ts │ │ │ ├── note.ts │ │ │ ├── space.ts │ │ │ └── http.ts │ │ ├── main.tsx │ │ ├── hooks │ │ │ ├── useYjsSpaceAwareness.ts │ │ │ ├── useSpaceSelection.ts │ │ │ ├── useAutofit.ts │ │ │ ├── useMilkdownCollab.ts │ │ │ ├── yjs │ │ │ │ ├── useY.ts │ │ │ │ ├── useYjsConnection.tsx │ │ │ │ └── useYjsAwareness.ts │ │ │ ├── useMilkdownEditor.ts │ │ │ ├── useDragNode.ts │ │ │ ├── useZoomSpace.ts │ │ │ └── useMoveNode.ts │ │ ├── store │ │ │ └── yjs.ts │ │ ├── pages │ │ │ ├── NotFound.tsx │ │ │ └── Space.tsx │ │ ├── components │ │ │ ├── space │ │ │ │ ├── context-menu │ │ │ │ │ ├── CustomContextMenu.tsx │ │ │ │ │ ├── type.ts │ │ │ │ │ └── SpaceContextMenuWrapper.tsx │ │ │ │ ├── YjsSpaceView.tsx │ │ │ │ ├── GooeyNode.tsx │ │ │ │ ├── NearNodeIndicator.tsx │ │ │ │ ├── SpaceNode.stories.tsx │ │ │ │ ├── SpaceNode.tsx │ │ │ │ ├── InteractionGuide.tsx │ │ │ │ ├── SpacePageHeader.tsx │ │ │ │ └── GooeyConnection.tsx │ │ │ ├── ErrorSection.tsx │ │ │ ├── ui │ │ │ │ ├── label.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── hover-card.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── breadcrumb.tsx │ │ │ │ └── dialog.tsx │ │ │ ├── note │ │ │ │ ├── Editor.tsx │ │ │ │ ├── Block.tsx │ │ │ │ └── Editor.css │ │ │ ├── SpaceUsersIndicator.tsx │ │ │ ├── SpaceShareAlertContent.tsx │ │ │ ├── PointerLayer.tsx │ │ │ ├── PointerCursor.tsx │ │ │ ├── Edge.tsx │ │ │ └── SpaceBreadcrumb.tsx │ │ ├── lib │ │ │ ├── polyfill-relative-urls-websocket.ts │ │ │ ├── milkdown-plugin-placeholder.ts │ │ │ ├── prompt-dialog.tsx │ │ │ └── utils.ts │ │ ├── assets │ │ │ ├── shapes.ts │ │ │ └── error-logo.svg │ │ ├── App.tsx │ │ └── index.css │ ├── postcss.config.js │ ├── public │ │ ├── og-image.png │ │ ├── PretendardVariable.woff2 │ │ ├── home-bg.svg │ │ └── favicon.svg │ ├── .prettierrc │ ├── .storybook │ │ ├── preview.ts │ │ └── main.ts │ ├── tsconfig.json │ ├── vite.config.ts │ ├── .gitignore │ ├── components.json │ ├── tsconfig.node.json │ ├── tsconfig.app.json │ ├── index.html │ ├── Dockerfile │ ├── eslint.config.js │ ├── tailwind.config.js │ ├── nginx.conf │ └── package.json └── .env.example ├── commitlint.config.mjs ├── .eslintignore ├── tsconfig.json ├── .prettierrc ├── example ├── .env.example └── docker-compose.db.yml ├── .gitignore ├── docker-compose.yml ├── package.json ├── eslint.config.mjs ├── docker-compose.prod.yml └── docker-compose.override.yml /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm lint-staged 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @boostcampwm-2024/web29 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | pnpm dlx commitlint --edit $1 2 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "packages/*" 3 | -------------------------------------------------------------------------------- /packages/shared/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shared" 3 | } 4 | -------------------------------------------------------------------------------- /packages/backend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .git 3 | .gitignore 4 | *.md 5 | dist -------------------------------------------------------------------------------- /packages/frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .git 3 | .gitignore 4 | *.md 5 | dist -------------------------------------------------------------------------------- /commitlint.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | extends: ["@commitlint/config-conventional"], 3 | }; 4 | -------------------------------------------------------------------------------- /packages/frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | export default {}; 3 | -------------------------------------------------------------------------------- /packages/backend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "endOfLine": "auto" 5 | } 6 | -------------------------------------------------------------------------------- /packages/backend/src/common/constants/space.constants.ts: -------------------------------------------------------------------------------- 1 | export const MAX_SPACES = 9999; 2 | export const GUEST_USER_ID = 'honeyflow'; 3 | -------------------------------------------------------------------------------- /packages/frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /packages/frontend/public/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web29-honeyflow/HEAD/packages/frontend/public/og-image.png -------------------------------------------------------------------------------- /packages/backend/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/frontend/public/PretendardVariable.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web29-honeyflow/HEAD/packages/frontend/public/PretendardVariable.woff2 -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # Backend 2 | /packages/backend/* 3 | /packages/backend/**/* 4 | 5 | # Build outputs 6 | **/dist 7 | **/build 8 | **/coverage 9 | 10 | # Dependencies 11 | **/node_modules -------------------------------------------------------------------------------- /.github/workflows/reviewers.json: -------------------------------------------------------------------------------- 1 | { 2 | "fru1tworld": "U07H6QPSEE7", 3 | "heegenie": "U07GSBFR0Q7", 4 | "CatyJazzy": "U07GSBRJF9D", 5 | "parkblo": "U07H6TN3WTC", 6 | "hoqn": "U07HKHTP2TT" 7 | } 8 | -------------------------------------------------------------------------------- /packages/backend/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/frontend/src/App.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | width: 100%; 4 | height: 100%; 5 | padding: 0; 6 | margin: 0; 7 | } 8 | 9 | #root { 10 | width: 100%; 11 | min-height: 100%; 12 | height: 100%; 13 | } 14 | -------------------------------------------------------------------------------- /packages/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 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "commonjs", 5 | "esModuleInterop": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "strict": true, 8 | "skipLibCheck": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/backend/src/note/dto/note.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class CreateNoteDto { 4 | @ApiProperty({ description: '유저 ID' }) 5 | userId: string; 6 | @ApiProperty({ description: '노트 제목' }) 7 | noteName: string; 8 | } 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": false, 3 | "tabWidth": 2, 4 | "semi": true, 5 | "printWidth": 80, 6 | "arrowParens": "always", 7 | "jsxSingleQuote": false, 8 | "bracketSpacing": true, 9 | "trailingComma": "all", 10 | "jsxBracketSameLine": false 11 | } 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue-template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Issue Template 3 | about: "(Default Template)" 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 🔍 이슈 설명 11 | 12 | 13 | ## ✅ 인수 조건 14 | 15 | 16 | ## 🚜 작업 사항 17 | - [ ] 18 | 19 | ## 📌 참고 자료 20 | -------------------------------------------------------------------------------- /packages/backend/src/test/mock/mock.constants.ts: -------------------------------------------------------------------------------- 1 | export const MOCK_CONFIG = { 2 | ITERATIONS: { 3 | DEFAULT: 1000, 4 | CONCURRENT: 500, 5 | }, 6 | TEST_PREFIX: { 7 | SPACE: 'test-space-', 8 | NOTE: 'test-note-', 9 | CONCURRENT: 'concurrent-test-space-', 10 | }, 11 | } as const; 12 | -------------------------------------------------------------------------------- /packages/frontend/src/api/constants.ts: -------------------------------------------------------------------------------- 1 | export const API_V1_URL = import.meta.env.DEV 2 | ? "http://localhost/api/v1" 3 | : "/api/v1"; 4 | 5 | export const API_V2_URL = import.meta.env.DEV 6 | ? "http://localhost/api/v2" 7 | : "/api/v2"; 8 | 9 | export const WS_URL = import.meta.env.DEV ? "ws://localhost/ws" : "/ws"; 10 | -------------------------------------------------------------------------------- /example/.env.example: -------------------------------------------------------------------------------- 1 | # Redis 2 | REDIS_PASSWORD=your_redis_password 3 | 4 | # MongoDB 5 | MONGO_USERNAME=your_mongo_username 6 | MONGO_PASSWORD=your_mongo_password 7 | 8 | # MySQL 9 | MYSQL_ROOT_PASSWORD=your_mysql_root_password 10 | MYSQL_DATABASE=your_database_name 11 | MYSQL_USER=your_mysql_username 12 | MYSQL_PASSWORD=your_mysql_password -------------------------------------------------------------------------------- /packages/frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "prettier-plugin-tailwindcss", 4 | "@trivago/prettier-plugin-sort-imports" 5 | ], 6 | "importOrder": ["^react", "", "^@/(.*)$", "^[./]"], 7 | "importOrderSeparation": true, 8 | "importOrderSortSpecifiers": true, 9 | "endOfLine": "auto" 10 | } 11 | -------------------------------------------------------------------------------- /packages/frontend/.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import type { Preview } from "@storybook/react"; 2 | 3 | const preview: Preview = { 4 | parameters: { 5 | controls: { 6 | matchers: { 7 | color: /(background|color)$/i, 8 | date: /Date$/i, 9 | }, 10 | }, 11 | }, 12 | }; 13 | 14 | export default preview; 15 | -------------------------------------------------------------------------------- /packages/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "files": [], 4 | "references": [ 5 | { "path": "./tsconfig.app.json" }, 6 | { "path": "./tsconfig.node.json" } 7 | ], 8 | "compilerOptions": { 9 | "baseUrl": ".", 10 | "paths": { 11 | "@/*": ["./src/*"] 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/shared/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "files": [], 4 | "references": [ 5 | { "path": "./tsconfig.app.json" }, 6 | { "path": "./tsconfig.node.json" } 7 | ], 8 | "compilerOptions": { 9 | "baseUrl": ".", 10 | "paths": { 11 | "@/*": ["./src/*"] 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react"; 2 | import path from "path"; 3 | import { defineConfig } from "vite"; 4 | 5 | // https://vite.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react()], 8 | resolve: { 9 | alias: { 10 | "@": path.resolve(__dirname, "./src"), 11 | }, 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /packages/backend/src/space/dto/create.space.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class CreateSpaceDto { 4 | @ApiProperty({ description: '유저 ID' }) 5 | userId: string; 6 | @ApiProperty({ description: '스페이스 이름' }) 7 | spaceName: string; 8 | @ApiProperty({ description: 'Parent Space Id' }) 9 | parentContextNodeId: string | null; 10 | } 11 | -------------------------------------------------------------------------------- /packages/backend/src/space/dto/update.space.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class UpdateSpaceDto { 4 | @ApiProperty({ description: '유저 ID' }) 5 | userId: string; 6 | @ApiProperty({ description: '스페이스 이름' }) 7 | spaceName: string; 8 | 9 | @ApiProperty({ description: 'Parent Space Id' }) 10 | parentContextNodeId: string | null; 11 | } 12 | -------------------------------------------------------------------------------- /packages/frontend/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | 4 | import "@/lib/polyfill-relative-urls-websocket.ts"; 5 | 6 | import App from "./App.tsx"; 7 | import "./index.css"; 8 | 9 | export default {}; 10 | createRoot(document.getElementById("root")!).render( 11 | 12 | 13 | , 14 | ); 15 | -------------------------------------------------------------------------------- /packages/backend/src/collaborative/collaborative.type.ts: -------------------------------------------------------------------------------- 1 | export interface SpaceDocument { 2 | id: string; 3 | parentContextNodeId: string | null; 4 | edges: string; 5 | nodes: string; 6 | } 7 | 8 | export interface NoteDocument { 9 | id: string; 10 | content: string; 11 | } 12 | 13 | export type DocumentType = 'space' | 'note'; 14 | export type Document = SpaceDocument | NoteDocument; 15 | -------------------------------------------------------------------------------- /packages/frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | *storybook.log 27 | -------------------------------------------------------------------------------- /packages/frontend/src/hooks/useYjsSpaceAwareness.ts: -------------------------------------------------------------------------------- 1 | import { SpaceAwarenessState } from "shared/types"; 2 | import { WebsocketProvider } from "y-websocket"; 3 | 4 | import useYjsAwarenessStates from "./yjs/useYjsAwareness"; 5 | 6 | type Awareness = WebsocketProvider["awareness"]; 7 | 8 | export default function useYjsSpaceAwarenessStates(awareness?: Awareness) { 9 | return useYjsAwarenessStates(awareness); 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | .eslintcache 10 | 11 | node_modules 12 | dist 13 | dist-ssr 14 | *.local 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | 27 | # env 28 | .env 29 | 30 | # develop 31 | */backend/docker.compose.yml 32 | -------------------------------------------------------------------------------- /packages/backend/src/collaborative/collaborative.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { NoteModule } from '../note/note.module'; 4 | import { SpaceModule } from '../space/space.module'; 5 | import { CollaborativeService } from './collaborative.service'; 6 | 7 | @Module({ 8 | imports: [NoteModule, SpaceModule], 9 | providers: [CollaborativeService], 10 | exports: [CollaborativeService], 11 | }) 12 | export class CollaborativeModule {} 13 | -------------------------------------------------------------------------------- /packages/backend/src/yjs/yjs.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { CollaborativeModule } from '../collaborative/collaborative.module'; 4 | import { NoteModule } from '../note/note.module'; 5 | import { SpaceModule } from '../space/space.module'; 6 | import { YjsGateway } from './yjs.gateway'; 7 | 8 | @Module({ 9 | imports: [SpaceModule, NoteModule, CollaborativeModule], 10 | providers: [YjsGateway], 11 | }) 12 | export class YjsModule {} 13 | -------------------------------------------------------------------------------- /packages/frontend/.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from "@storybook/react-vite"; 2 | 3 | const config: StorybookConfig = { 4 | stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], 5 | addons: [ 6 | "@storybook/addon-onboarding", 7 | "@storybook/addon-essentials", 8 | "@chromatic-com/storybook", 9 | "@storybook/addon-interactions", 10 | ], 11 | framework: { 12 | name: "@storybook/react-vite", 13 | options: {}, 14 | }, 15 | }; 16 | export default config; 17 | -------------------------------------------------------------------------------- /packages/frontend/src/api/note.ts: -------------------------------------------------------------------------------- 1 | import { API_V1_URL } from "./constants"; 2 | import http from "./http"; 3 | 4 | type CreateNoteRequestBody = { 5 | userId: string; 6 | noteName: string; 7 | }; 8 | 9 | type CreateNoteResponseBody = { 10 | urlPath: string; 11 | }; 12 | 13 | export async function createNote(body: CreateNoteRequestBody) { 14 | const response = await http.post( 15 | `${API_V1_URL}/note`, 16 | { body: JSON.stringify(body) }, 17 | ); 18 | return response.data; 19 | } 20 | -------------------------------------------------------------------------------- /packages/backend/src/test/types/performance.types.ts: -------------------------------------------------------------------------------- 1 | export interface PerformanceResult { 2 | mysqlDuration: number; 3 | mongoDuration: number; 4 | difference: number; 5 | } 6 | 7 | export interface MockSpace { 8 | id: string; 9 | userId: string; 10 | name: string; 11 | edges: string; 12 | nodes: string; 13 | createdAt: Date; 14 | updatedAt: Date; 15 | } 16 | 17 | export interface MockNote { 18 | id: string; 19 | userId: string; 20 | name: string; 21 | content: string; 22 | createdAt: Date; 23 | updatedAt: Date; 24 | } 25 | -------------------------------------------------------------------------------- /packages/frontend/src/store/yjs.ts: -------------------------------------------------------------------------------- 1 | import * as Y from "yjs"; 2 | 3 | import { createSafeContext } from "@/lib/utils"; 4 | 5 | interface YjsStoreState { 6 | yProvider?: Y.AbstractConnector; 7 | yDoc?: Y.Doc; 8 | } 9 | 10 | interface YjsStoreActions { 11 | setYProvider: (provider: YjsStoreState["yProvider"]) => void; 12 | setYDoc: (doc: YjsStoreState["yDoc"]) => void; 13 | } 14 | 15 | const [useYjsStore, YjsStoreProvider] = createSafeContext< 16 | YjsStoreState & YjsStoreActions 17 | >(); 18 | 19 | export { useYjsStore, YjsStoreProvider }; 20 | -------------------------------------------------------------------------------- /packages/frontend/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/index.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } 22 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | backend: 3 | build: 4 | context: . 5 | dockerfile: ./packages/backend/Dockerfile 6 | restart: unless-stopped 7 | environment: 8 | - NODE_ENV=development 9 | networks: 10 | - app-network 11 | 12 | frontend: 13 | build: 14 | context: . 15 | dockerfile: ./packages/frontend/Dockerfile 16 | restart: unless-stopped 17 | environment: 18 | - NODE_ENV=development 19 | networks: 20 | - app-network 21 | 22 | networks: 23 | app-network: 24 | driver: bridge 25 | -------------------------------------------------------------------------------- /packages/backend/src/common/constants/websocket.constants.ts: -------------------------------------------------------------------------------- 1 | export const WebsocketStatus = { 2 | NORMAL_CLOSURE: 1000, 3 | GOING_AWAY: 1001, 4 | PROTOCOL_ERROR: 1002, 5 | UNSUPPORTED_DATA: 1003, 6 | RESERVED: 1004, 7 | NO_STATUS_RECEIVED: 1005, 8 | ABNORMAL_CLOSURE: 1006, 9 | INVALID_FRAME_PAYLOAD: 1007, 10 | POLICY_VIOLATION: 1008, 11 | MESSAGE_TOO_BIG: 1009, 12 | MANDATORY_EXTENSION: 1010, 13 | INTERNAL_SERVER_ERROR: 1011, 14 | SERVICE_RESTART: 1012, 15 | TRY_AGAIN_LATER: 1013, 16 | BAD_GATEWAY: 1014, 17 | TLS_HANDSHAKE_FAILURE: 1015, 18 | }; 19 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | > [!NOTE] 2 | > PR 작성 전 체크 리스트 3 | > - Assignees에 PR 작성자를 추가해 주세요. 4 | > - PR에 라벨과 프로젝트를 설정해 주세요. 5 | > - PR 생성 후 2명의 리뷰어가 잘 설정되었는지 확인해 주세요. 6 | 7 | **** 8 | 9 | --- 절취선 --- 10 | 11 | ## ✏️ 한 줄 설명 12 | > 이 PR의 주요 변경 사항이나 구현된 내용을 간략히 설명해 주세요. 13 | 14 | 15 | ## ✅ 작업 내용 16 | - 17 | 18 | ## 🏷️ 관련 이슈 19 | - 20 | 21 | ## 📸 스크린샷/영상 22 | > 이번 PR에서 변경되거나 추가된 뷰가 있는 경우 이미지나 동작 영상을 첨부해 주세요. 23 | 24 | 25 | ## 📌 리뷰 진행 시 참고 사항 26 | > 리뷰 코멘트 작성 시 특정 사실에 대해 짚는 것이 아니라 코드에 대한 의견을 제안할 경우, 강도를 함께 제시해주세요! (1점: 가볍게 참고해봐도 좋을듯 ↔ 5점: 꼭 바꾸는 게 좋을 것 같음!) -------------------------------------------------------------------------------- /packages/backend/src/note/note.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MongooseModule } from '@nestjs/mongoose'; 3 | 4 | import { NoteController } from './note.controller'; 5 | import { NoteDocument, NoteSchema } from './note.schema'; 6 | import { NoteService } from './note.service'; 7 | 8 | @Module({ 9 | imports: [ 10 | MongooseModule.forFeature([ 11 | { name: NoteDocument.name, schema: NoteSchema }, 12 | ]), 13 | ], 14 | controllers: [NoteController], 15 | providers: [NoteService], 16 | exports: [NoteService], 17 | }) 18 | export class NoteModule {} 19 | -------------------------------------------------------------------------------- /packages/frontend/src/pages/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "react-router-dom"; 2 | 3 | import { MoveLeftIcon } from "lucide-react"; 4 | 5 | import ErrorSection from "@/components/ErrorSection"; 6 | import { Button } from "@/components/ui/button"; 7 | 8 | export default function NotFoundPage() { 9 | return ( 10 | ( 13 | 19 | )} 20 | /> 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /packages/backend/src/note/note.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import { Document } from 'mongoose'; 3 | 4 | @Schema({ 5 | timestamps: true, 6 | versionKey: false, 7 | }) 8 | export class NoteDocument extends Document { 9 | @Prop({ required: true, unique: true }) 10 | id: string; 11 | 12 | @Prop({ required: true }) 13 | userId: string; 14 | 15 | @Prop({ required: true }) 16 | name: string; 17 | 18 | @Prop({ type: String, default: null }) 19 | content: string | null; 20 | } 21 | 22 | export const NoteSchema = SchemaFactory.createForClass(NoteDocument); 23 | -------------------------------------------------------------------------------- /packages/frontend/src/components/space/context-menu/CustomContextMenu.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ContextMenuContent, 3 | ContextMenuItem, 4 | } from "@/components/ui/context-menu"; 5 | 6 | import { ContextMenuItemConfig } from "./type"; 7 | 8 | type CustomContextMenuProps = { 9 | items: ContextMenuItemConfig[]; 10 | }; 11 | 12 | export default function CustomContextMenu({ items }: CustomContextMenuProps) { 13 | return ( 14 | 15 | {items.map((item) => ( 16 | {item.label} 17 | ))} 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /packages/frontend/src/lib/polyfill-relative-urls-websocket.ts: -------------------------------------------------------------------------------- 1 | // Monkey Patch로 WebSocket 생성자를 오버라이드 2 | const OriginalWebSocket = window.WebSocket; 3 | 4 | // @ts-expect-error Monkey Patch 5 | window.WebSocket = function WebSocket(url, protocols) { 6 | const parsedUrl = new URL(url, window.location.href); 7 | 8 | if (parsedUrl.protocol === "http:") { 9 | parsedUrl.protocol = "ws:"; 10 | } else if (parsedUrl.protocol === "https:") { 11 | parsedUrl.protocol = "wss:"; 12 | } 13 | 14 | return new OriginalWebSocket(parsedUrl.href, protocols); 15 | }; 16 | 17 | window.WebSocket.prototype = OriginalWebSocket.prototype; 18 | -------------------------------------------------------------------------------- /packages/frontend/src/assets/shapes.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * SVG 도형을 그리기 위한 path 데이터 문자열 3 | * 4 | * M: 시작점 이동 (Move to) 5 | * Q: 베지어 곡선 (Quadratic Bezier curve) 6 | * Z: 경로 닫기 (Close path) 7 | * 8 | * 값 형식: x y 좌표 (0~100 범위의 상대값) 9 | */ 10 | 11 | export const circle = ` 12 | M 50 0 13 | Q 85 0, 92.5 25 14 | Q 110 50, 92.5 75 15 | Q 85 100, 50 100 16 | Q 15 100, 7.5 75 17 | Q -10 50, 7.5 25 18 | Q 15 0, 50 0 19 | Z 20 | `; 21 | 22 | export const hexagon = ` 23 | M 50 0 24 | Q 71.65 12.5, 93.3 25 25 | Q 93.3 50, 93.3 75 26 | Q 71.65 87.5, 50 100 27 | Q 28.35 87.5, 6.7 75 28 | Q 6.7 50, 6.7 25 29 | Q 28.35 12.5, 50 0 30 | Z 31 | `; 32 | -------------------------------------------------------------------------------- /packages/backend/src/common/filters/all-exceptions.filter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExceptionFilter, 3 | Catch, 4 | HttpException, 5 | ArgumentsHost, 6 | } from '@nestjs/common'; 7 | import { Response } from 'express'; 8 | 9 | @Catch(HttpException) 10 | export class AllExceptionsFilter implements ExceptionFilter { 11 | catch(exception: HttpException, host: ArgumentsHost) { 12 | const ctx = host.switchToHttp(); 13 | const response = ctx.getResponse(); 14 | const status = exception.getStatus(); 15 | const message = exception.message || 'Internal server error'; 16 | response.status(status).json({ 17 | statusCode: status, 18 | message, 19 | }); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/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 | -------------------------------------------------------------------------------- /packages/backend/src/test/note.test.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | Column, 4 | PrimaryGeneratedColumn, 5 | CreateDateColumn, 6 | UpdateDateColumn, 7 | } from 'typeorm'; 8 | 9 | @Entity('notes') 10 | export class Note { 11 | @PrimaryGeneratedColumn('uuid') 12 | id: string; 13 | 14 | @Column({ type: 'varchar', length: 255, nullable: false }) 15 | userId: string; 16 | 17 | @Column({ type: 'varchar', length: 255, nullable: false }) 18 | name: string; 19 | 20 | @Column({ type: 'text', nullable: true, default: null }) 21 | content: string | null; 22 | 23 | @CreateDateColumn({ type: 'timestamp' }) 24 | createdAt: Date; 25 | 26 | @UpdateDateColumn({ type: 'timestamp' }) 27 | updatedAt: Date; 28 | } 29 | -------------------------------------------------------------------------------- /packages/backend/src/common/utils/socket.util.ts: -------------------------------------------------------------------------------- 1 | export function parseSocketUrl(url: string): { 2 | urlType: string | null; 3 | urlId: string | null; 4 | } { 5 | try { 6 | const isAbsoluteUrl = url.startsWith('ws://') || url.startsWith('wss://'); 7 | 8 | const baseUrl = 'ws://localhost'; 9 | const fullUrl = isAbsoluteUrl ? url : `${baseUrl}${url}`; 10 | const { pathname } = new URL(fullUrl); 11 | 12 | const parts = pathname.split('/').filter((part) => part.length > 0); 13 | 14 | if (parts.length >= 2) { 15 | return { urlType: parts[1], urlId: parts[2] }; 16 | } 17 | return { urlType: null, urlId: null }; 18 | 19 | } catch (error) { 20 | return { urlType: null, urlId: null }; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/backend/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /packages/.env.example: -------------------------------------------------------------------------------- 1 | # MySQL 설정 2 | MYSQL_HOST=your-mysql-host 3 | MYSQL_PORT=3306 4 | MYSQL_USER=your-mysql-username 5 | MYSQL_PASSWORD=your-mysql-password 6 | MYSQL_DATABASE=your-mysql-database 7 | 8 | # MongoDB 설정 9 | MONGO_HOST=your-mongo-host 10 | MONGO_USER=your-mongo-username 11 | MONGO_PASSWORD=your-mongo-password 12 | MONGO_DB=your-mongo-database 13 | 14 | # Redis 설정 (추가된 telnet 기반 체크를 위해 필요) 15 | REDIS_HOST=your-redis-host 16 | REDIS_PORT=6379 17 | 18 | # 기타 설정 19 | DATABASE_HOST=your-database-host 20 | LOG_LEVEL=info 21 | 22 | # GitHub Actions에 필요한 설정 23 | SERVER_HOST=your-server-ip 24 | SERVER_USER=your-server-username 25 | SERVER_SSH_PORT=22 26 | SSH_KEY=your-ssh-private-key-content 27 | ENV_FILE_CONTENTS=$(cat .env) # GitHub Secrets로 저장된 ENV 파일 내용 28 | -------------------------------------------------------------------------------- /packages/backend/src/space/space.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MongooseModule } from '@nestjs/mongoose'; 3 | 4 | import { SpaceController } from './space.controller'; 5 | import { SpaceDocument, SpaceSchema } from './space.schema'; 6 | import { SpaceService } from './space.service'; 7 | import { SpaceValidation } from './space.validation.service'; 8 | import { NoteModule } from 'src/note/note.module'; 9 | 10 | @Module({ 11 | imports: [ 12 | NoteModule, 13 | MongooseModule.forFeature([ 14 | { name: SpaceDocument.name, schema: SpaceSchema }, 15 | ]), 16 | ], 17 | controllers: [SpaceController], 18 | providers: [SpaceService, SpaceValidation], 19 | exports: [SpaceService], 20 | }) 21 | export class SpaceModule {} 22 | -------------------------------------------------------------------------------- /packages/frontend/src/components/space/YjsSpaceView.tsx: -------------------------------------------------------------------------------- 1 | import { RefObject } from "react"; 2 | 3 | import useYjsConnection from "@/hooks/yjs/useYjsConnection"; 4 | import { YjsStoreProvider } from "@/store/yjs"; 5 | 6 | import SpaceView from "./SpaceView"; 7 | 8 | type YjsSpaceViewProps = { 9 | spaceId: string; 10 | autofitTo?: Element | RefObject; 11 | }; 12 | 13 | export default function YjsSpaceView({ 14 | spaceId, 15 | autofitTo, 16 | }: YjsSpaceViewProps) { 17 | const { yDoc, yProvider, setYDoc, setYProvider } = useYjsConnection(spaceId); 18 | 19 | return ( 20 | 21 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /packages/backend/src/space/space.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import { Document } from 'mongoose'; 3 | 4 | @Schema({ 5 | timestamps: true, 6 | versionKey: false, 7 | }) 8 | export class SpaceDocument extends Document { 9 | @Prop({ required: true, unique: true }) 10 | id: string; 11 | 12 | @Prop({ type: String, default: null, index: true }) 13 | parentSpaceId: string | null; 14 | 15 | @Prop({ required: true }) 16 | userId: string; 17 | 18 | @Prop({ required: true }) 19 | name: string; 20 | 21 | @Prop({ required: true, type: String }) 22 | edges: string; 23 | 24 | @Prop({ required: true, type: String }) 25 | nodes: string; 26 | } 27 | 28 | export const SpaceSchema = SchemaFactory.createForClass(SpaceDocument); 29 | -------------------------------------------------------------------------------- /packages/frontend/public/home-bg.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/backend/src/common/config/mongo.config.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from '@nestjs/config'; 2 | import { MongooseModuleOptions } from '@nestjs/mongoose'; 3 | 4 | export const getMongooseConfig = ( 5 | configService: ConfigService, 6 | ): MongooseModuleOptions => { 7 | const host = configService.get('MONGO_HOST'); 8 | const user = configService.get('MONGO_USER'); 9 | const pass = configService.get('MONGO_PASSWORD'); 10 | const dbName = configService.get('MONGO_DB'); 11 | 12 | const uri = `mongodb://${user}:${pass}@${host}:27017/${dbName}`; 13 | 14 | return { 15 | uri, 16 | authSource: 'admin', 17 | authMechanism: 'SCRAM-SHA-256', 18 | connectionFactory: (connection) => { 19 | return connection; 20 | }, 21 | }; 22 | }; 23 | -------------------------------------------------------------------------------- /packages/backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": ".", 13 | "incremental": true, 14 | "esModuleInterop": true, 15 | "skipLibCheck": true, 16 | "strictNullChecks": true, 17 | "noImplicitAny": false, 18 | "strictBindCallApply": false, 19 | "forceConsistentCasingInFileNames": false, 20 | "noFallthroughCasesInSwitch": false, 21 | "noEmit": false 22 | }, 23 | "include": ["src/**/*", "../../test"], 24 | "exclude": ["node_modules", "dist"] 25 | } 26 | -------------------------------------------------------------------------------- /packages/frontend/src/components/space/context-menu/type.ts: -------------------------------------------------------------------------------- 1 | import { Node } from "shared/types"; 2 | 3 | export type ContextMenuItemConfig = { 4 | label: string; 5 | action: () => void; 6 | }; 7 | 8 | export type SelectedNodeInfo = { 9 | id: string; 10 | type: Exclude; 11 | src?: string | undefined; 12 | }; 13 | 14 | export type SelectedEdgeInfo = { 15 | id: string; 16 | }; 17 | 18 | export type ContextMenuActions = { 19 | onNodeUpdate: (nodeId: string, patch: Partial) => void; 20 | onNodeDelete: (nodeId: string) => void; 21 | onEdgeDelete: (edgeId: string) => void; 22 | }; 23 | 24 | export type SelectionState = { 25 | selectedNode: SelectedNodeInfo | null; 26 | selectedEdge: SelectedEdgeInfo | null; 27 | clearSelection: () => void; 28 | }; 29 | -------------------------------------------------------------------------------- /packages/backend/src/common/constants/error.message.constants.ts: -------------------------------------------------------------------------------- 1 | export const ERROR_MESSAGES = { 2 | SPACE: { 3 | BAD_REQUEST: '잘못된 요청입니다.', 4 | LIMIT_EXCEEDED: '스페이스 최대 생성한도 초과', 5 | NOT_FOUND: '존재하지 않는 스페이스입니다.', 6 | CREATION_FAILED: '스페이스 생성에 실패하였습니다.', 7 | UPDATE_FAILED: '스페이스 업데이트에 실패하였습니다.', 8 | INITIALIZE_FAILED: '스페이스가 초기화에 실패하였습니다.', 9 | PARENT_NOT_FOUND: '부모 스페이스가 존재하지 않습니다.', 10 | DELETE_FAILED: '노트 삭제에 실패하였습니다.', 11 | }, 12 | NOTE: { 13 | BAD_REQUEST: '잘못된 요청입니다.', 14 | NOT_FOUND: '노트가 존재하지 않습니다.', 15 | CREATION_FAILED: '노트 생성에 실패하였습니다.', 16 | UPDATE_FAILED: '노트 업데이트에 실패하였습니다.', 17 | INITIALIZE_FAILED: '노트가 초기화에 실패하였습니다.', 18 | DELETE_FAILED: '노트 삭제에 실패하였습니다.', 19 | }, 20 | SOCKET: { 21 | INVALID_URL: '유효하지 않은 URL 주소입니다.', 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /packages/shared/types/index.ts: -------------------------------------------------------------------------------- 1 | export type Node = { 2 | id: string; 3 | name: string; 4 | x: number; 5 | y: number; 6 | type: "head" | "note" | "url" | "image" | "subspace"; 7 | src?: string; 8 | }; 9 | 10 | export type Edge = { 11 | from: Node; 12 | to: Node; 13 | }; 14 | 15 | export type EdgeWithId = { 16 | from: string; 17 | to: string; 18 | }; 19 | 20 | export type SpaceData = { 21 | id: string; 22 | parentContextId?: string; 23 | edges: Record; // 24 | nodes: Record; 25 | }; 26 | 27 | export type SpaceAwarenessState = { 28 | color: string; 29 | pointer?: { x: number; y: number }; 30 | }; 31 | 32 | export type BreadcrumbItem = { 33 | name: string; 34 | url: string; 35 | }; 36 | export type BreadCrumb = { 37 | urlPaths: []; 38 | }; 39 | -------------------------------------------------------------------------------- /packages/frontend/src/components/space/GooeyNode.tsx: -------------------------------------------------------------------------------- 1 | import { Circle } from "react-konva"; 2 | 3 | import { Vector2d } from "konva/lib/types"; 4 | 5 | import GooeyConnection from "./GooeyConnection"; 6 | 7 | type GooeyNodeProps = { 8 | startPosition: Vector2d; 9 | dragPosition: Vector2d; 10 | connectionVisible?: boolean; 11 | color?: string; 12 | }; 13 | 14 | export default function GooeyNode({ 15 | startPosition, 16 | dragPosition, 17 | connectionVisible = true, 18 | color = "#FFF2CB", 19 | }: GooeyNodeProps) { 20 | return ( 21 | <> 22 | 23 | {connectionVisible && ( 24 | 28 | )} 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /packages/frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter, Route, Routes } from "react-router-dom"; 2 | 3 | import "./App.css"; 4 | import Editor from "./components/note/Editor.tsx"; 5 | import { PromptDialogPortal } from "./lib/prompt-dialog.tsx"; 6 | import Home from "./pages/Home.tsx"; 7 | import NotFoundPage from "./pages/NotFound.tsx"; 8 | import SpacePage from "./pages/Space.tsx"; 9 | 10 | function App() { 11 | return ( 12 | <> 13 | 14 | 15 | 16 | } /> 17 | } /> 18 | } /> 19 | } /> 20 | 21 | 22 | 23 | ); 24 | } 25 | 26 | export default App; 27 | -------------------------------------------------------------------------------- /packages/frontend/src/components/ErrorSection.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | import buzzyLogo from "@/assets/error-logo.svg"; 4 | 5 | type ErrorSectionProps = { 6 | description: string; 7 | RestoreActions?: () => ReactNode; 8 | }; 9 | 10 | export default function ErrorSection({ 11 | description, 12 | RestoreActions, 13 | }: ErrorSectionProps) { 14 | return ( 15 |
16 |
17 | 18 |
19 |

{description}

20 |
21 | {RestoreActions && ( 22 |
23 | 24 |
25 | )} 26 |
27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /packages/frontend/src/components/space/NearNodeIndicator.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Circle } from "react-konva"; 3 | 4 | import { Node } from "shared/types"; 5 | 6 | type NearNodeIndicatorProps = { 7 | overlapNode: Node; 8 | }; 9 | 10 | function NearNodeIndicator({ overlapNode }: NearNodeIndicatorProps) { 11 | return ( 12 | 19 | ); 20 | } 21 | 22 | function areEqual( 23 | prevProps: NearNodeIndicatorProps, 24 | nextProps: NearNodeIndicatorProps, 25 | ) { 26 | return ( 27 | prevProps.overlapNode.x === nextProps.overlapNode.x && 28 | prevProps.overlapNode.y === nextProps.overlapNode.y 29 | ); 30 | } 31 | 32 | export const MemoizedNearIndicator = React.memo(NearNodeIndicator, areEqual); 33 | -------------------------------------------------------------------------------- /packages/frontend/src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import * as LabelPrimitive from "@radix-ui/react-label"; 4 | import { type VariantProps, cva } from "class-variance-authority"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | export default {}; 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", 11 | ); 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )); 24 | Label.displayName = LabelPrimitive.Root.displayName; 25 | 26 | export { Label }; 27 | -------------------------------------------------------------------------------- /packages/backend/src/test/space.test.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | Column, 4 | PrimaryGeneratedColumn, 5 | CreateDateColumn, 6 | UpdateDateColumn, 7 | Index, 8 | } from 'typeorm'; 9 | 10 | @Entity('spaces') 11 | export class Space { 12 | @PrimaryGeneratedColumn('uuid') 13 | id: string; 14 | 15 | @Column({ type: 'varchar', length: 255, nullable: true, default: null }) 16 | @Index() 17 | parentSpaceId: string | null; 18 | 19 | @Column({ type: 'varchar', length: 255, nullable: false }) 20 | userId: string; 21 | 22 | @Column({ type: 'varchar', length: 255, nullable: false }) 23 | name: string; 24 | 25 | @Column({ type: 'text', nullable: false }) 26 | edges: string; 27 | 28 | @Column({ type: 'text', nullable: false }) 29 | nodes: string; 30 | 31 | @CreateDateColumn({ type: 'timestamp' }) 32 | createdAt: Date; 33 | 34 | @UpdateDateColumn({ type: 'timestamp' }) 35 | updatedAt: Date; 36 | } 37 | -------------------------------------------------------------------------------- /packages/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 | 25 | /* Paths */ 26 | "baseUrl": ".", 27 | "paths": { 28 | "@/*": [ 29 | "./src/*" 30 | ] 31 | } 32 | }, 33 | "include": ["src"] 34 | } 35 | -------------------------------------------------------------------------------- /packages/frontend/src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | export default {}; 6 | const Input = React.forwardRef>( 7 | ({ className, type, ...props }, ref) => { 8 | return ( 9 | 18 | ); 19 | }, 20 | ); 21 | Input.displayName = "Input"; 22 | 23 | export { Input }; 24 | -------------------------------------------------------------------------------- /packages/backend/src/common/config/typeorm.config.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from '@nestjs/config'; 2 | import { TypeOrmModuleOptions } from '@nestjs/typeorm'; 3 | import { join } from 'path'; 4 | 5 | export const getTypeOrmConfig = async ( 6 | configService: ConfigService, 7 | ): Promise => { 8 | const host = configService.get('MYSQL_HOST'); 9 | const port = configService.get('MYSQL_PORT'); 10 | const database = configService.get('MYSQL_DATABASE'); 11 | const username = configService.get('MYSQL_USER'); 12 | const password = configService.get('MYSQL_PASSWORD'); 13 | 14 | return { 15 | type: 'mysql', 16 | host, 17 | port, 18 | username, 19 | password, 20 | database, 21 | entities: [join(__dirname, '..', '**', '*.entity.{ts,js}')], 22 | synchronize: process.env.NODE_ENV !== 'production', 23 | autoLoadEntities: true, 24 | logging: true, 25 | }; 26 | }; 27 | -------------------------------------------------------------------------------- /packages/backend/src/test/test.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { MongooseModule } from '@nestjs/mongoose'; 4 | import { TestController } from './test.controller'; 5 | import { TestService } from './test.service'; 6 | import { Space as SpaceEntity } from './space.test.entity'; 7 | import { Note as NoteEntity } from './note.test.entity'; 8 | import { SpaceDocument, SpaceSchema } from 'src/space/space.schema'; 9 | import { NoteDocument, NoteSchema } from 'src/note/note.schema'; 10 | 11 | @Module({ 12 | imports: [ 13 | TypeOrmModule.forFeature([SpaceEntity, NoteEntity]), 14 | MongooseModule.forFeature([ 15 | { name: SpaceDocument.name, schema: SpaceSchema }, 16 | { name: NoteDocument.name, schema: NoteSchema }, 17 | ]), 18 | ], 19 | controllers: [TestController], 20 | providers: [TestService], 21 | exports: [TestService], 22 | }) 23 | export class TestModule {} 24 | -------------------------------------------------------------------------------- /packages/backend/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | /build 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | pnpm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | 15 | # OS 16 | .DS_Store 17 | 18 | # Tests 19 | /coverage 20 | /.nyc_output 21 | a.env 22 | # IDEs and editors 23 | /.idea 24 | .project 25 | .classpath 26 | .c9/ 27 | *.launch 28 | .settings/ 29 | *.sublime-workspace 30 | 31 | # IDE - VSCode 32 | .vscode/* 33 | !.vscode/settings.json 34 | !.vscode/tasks.json 35 | !.vscode/launch.json 36 | !.vscode/extensions.json 37 | 38 | # dotenv environment variable files 39 | .env 40 | .env.development.local 41 | .env.test.local 42 | .env.production.local 43 | .env.local 44 | .env.prod 45 | 46 | # temp directory 47 | .temp 48 | .tmp 49 | 50 | # Runtime data 51 | pids 52 | *.pid 53 | *.seed 54 | *.pid.lock 55 | 56 | # Diagnostic reports (https://nodejs.org/api/report.html) 57 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 58 | -------------------------------------------------------------------------------- /packages/backend/src/test/mock/note.mock.data.ts: -------------------------------------------------------------------------------- 1 | import { MockNote } from '../types/performance.types'; 2 | 3 | export const noteMockData: MockNote[] = [ 4 | { 5 | id: 'c1ddbb14-689a-49ac-a2fc-a14aebb1c4ed', 6 | userId: 'test-user-id', 7 | name: 'test-name', 8 | content: 9 | 'AoIB+Ia//AoABwDujav1AwAGAQD4hr/8CgAEhPiGv/wKBAPthYyB+Ia//AoFAoT4hr/8CgcG7Iqk7Yq4h+6Nq/UDAAMJcGFyYWdyYXBoBwD4hr/8CgoGAQD4hr/8CgsDgfiGv/wKCgEABIH4hr/8Cg8BgfiGv/wKDgKE+Ia//AoWA+yXhIH4hr/8ChcChPiGv/wKGQPssq2B+Ia//AoaA4T4hr/8Ch0H64KY6rKMIIH4hr/8CiAChPiGv/wKIgTquLQggfiGv/wKJASE+Ia//AooA+usuIH4hr/8CikChPiGv/wKKwPsnpCB+Ia//AosAYT4hr/8Ci0D7Je0gfiGv/wKLgGE+Ia//AovA...', 10 | createdAt: new Date(), 11 | updatedAt: new Date(), 12 | }, 13 | { 14 | id: 'a4ac29f1-0504-43f4-b087-f47cf99b8186', 15 | userId: 'test-user-id', 16 | name: 'test-name', 17 | content: 'Different base64 encoded content...', 18 | createdAt: new Date(), 19 | updatedAt: new Date(), 20 | }, 21 | ]; 22 | -------------------------------------------------------------------------------- /packages/frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | HoneyFlow | 끈적끈적 협업 지식 관리 8 | 9 | 10 | 11 | 15 | 16 | 17 | 18 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /packages/frontend/src/hooks/useSpaceSelection.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | import { 4 | SelectedEdgeInfo, 5 | SelectedNodeInfo, 6 | } from "@/components/space/context-menu/type"; 7 | 8 | export default function useSpaceSelection() { 9 | const [selectedNode, setSelectNode] = useState(null); 10 | const [selectedEdge, setSelectedEdge] = useState( 11 | null, 12 | ); 13 | 14 | const selectNode = ({ id, type, src }: SelectedNodeInfo) => { 15 | setSelectNode({ 16 | id, 17 | type: type || null, 18 | src, 19 | }); 20 | setSelectedEdge(null); 21 | }; 22 | 23 | const selectEdge = ({ id }: SelectedEdgeInfo) => { 24 | setSelectedEdge({ 25 | id, 26 | }); 27 | setSelectNode(null); 28 | }; 29 | 30 | const clearSelection = () => { 31 | setSelectNode(null); 32 | setSelectedEdge(null); 33 | }; 34 | 35 | return { 36 | selectNode, 37 | selectEdge, 38 | selectedNode, 39 | selectedEdge, 40 | clearSelection, 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /packages/frontend/src/hooks/useAutofit.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect, useState } from "react"; 2 | 3 | export default function useAutofit( 4 | container: RefObject | T | undefined, 5 | ) { 6 | const [size, setSize] = useState({ width: 0, height: 0 }); 7 | 8 | useEffect(() => { 9 | if (!container) { 10 | return undefined; 11 | } 12 | 13 | const containerRef = 14 | "current" in container ? container : { current: container }; 15 | 16 | function resizeStage() { 17 | const container = containerRef.current; 18 | 19 | if (!container) { 20 | return; 21 | } 22 | 23 | const width = container.clientWidth; 24 | const height = container.clientHeight; 25 | 26 | setSize({ width, height }); 27 | } 28 | 29 | resizeStage(); 30 | 31 | // NOTE: ResizeObserver를 도입할 것을 고려할 것 32 | window.addEventListener("resize", resizeStage); 33 | return () => { 34 | window.removeEventListener("resize", resizeStage); 35 | }; 36 | }, [container]); 37 | 38 | return size; 39 | } 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web29-honeyflow", 3 | "version": "1.0.0", 4 | "description": "", 5 | "private": "true", 6 | "scripts": { 7 | "lint": "eslint --filter \"frontend,backend\" lint", 8 | "test": "echo \"Error: no test specified\" && exit 1", 9 | "prepare": "husky" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@commitlint/cli": "^19.5.0", 16 | "@commitlint/config-conventional": "^19.5.0", 17 | "eslint": "^9.14.0", 18 | "eslint-plugin-import": "^2.31.0", 19 | "husky": "^9.1.6", 20 | "lint-staged": "^15.2.10", 21 | "prettier": "3.3.3", 22 | "typescript": "^5.6.3", 23 | "typescript-eslint": "^8.13.0", 24 | "@eslint/eslintrc": "^3.1.0", 25 | "@eslint/js": "^9.14.0", 26 | "eslint-config-airbnb-base": "^15.0.0", 27 | "eslint-config-prettier": "^9.1.0", 28 | "eslint-plugin-prettier": "^5.2.1" 29 | }, 30 | "dependencies": {}, 31 | "lint-staged": { 32 | "src/**/*.{js,jsx,ts,tsx}": [ 33 | "eslint --cache --fix", 34 | "prettier --cache --write" 35 | ] 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine AS builder 2 | 3 | WORKDIR /app 4 | 5 | RUN apk add --no-cache python3 make g++ && npm install -g pnpm 6 | COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ 7 | COPY ./packages/backend ./packages/backend/ 8 | COPY ./packages/shared ./packages/shared/ 9 | 10 | RUN HUSKY=0 pnpm install --no-frozen-lockfile 11 | 12 | COPY ./packages/backend ./packages/backend 13 | COPY ./packages/shared ./packages/shared 14 | COPY ./tsconfig.json ./ 15 | RUN cd ./packages/backend && pnpm build 16 | 17 | FROM node:20-alpine AS production 18 | 19 | WORKDIR /app 20 | 21 | RUN npm install -g pnpm 22 | 23 | COPY --from=builder /app/package.json /app/pnpm-workspace.yaml ./ 24 | COPY --from=builder /app/packages/backend/package.json ./packages/backend/ 25 | COPY --from=builder /app/packages/shared/package.json ./packages/shared/ 26 | 27 | RUN HUSKY=0 pnpm install --no-frozen-lockfile --prod --ignore-scripts 28 | 29 | COPY --from=builder /app/packages/backend/dist ./packages/backend/dist 30 | 31 | COPY --from=builder /app/packages/shared ./packages/shared 32 | 33 | WORKDIR /app/packages/backend 34 | 35 | EXPOSE 3000 36 | 37 | CMD ["node", "dist/main"] -------------------------------------------------------------------------------- /packages/frontend/src/components/note/Editor.tsx: -------------------------------------------------------------------------------- 1 | import { useParams } from "react-router-dom"; 2 | 3 | import { Milkdown, MilkdownProvider } from "@milkdown/react"; 4 | import "@milkdown/theme-nord/style.css"; 5 | import { ProsemirrorAdapterProvider } from "@prosemirror-adapter/react"; 6 | 7 | import { WS_URL } from "@/api/constants"; 8 | import useMilkdownCollab from "@/hooks/useMilkdownCollab"; 9 | import useMilkdownEditor from "@/hooks/useMilkdownEditor"; 10 | 11 | import { BlockView } from "./Block"; 12 | import "./Editor.css"; 13 | 14 | function MilkdownEditor() { 15 | const { noteId } = useParams>(); 16 | 17 | const { loading, get } = useMilkdownEditor({ 18 | BlockView, 19 | }); 20 | 21 | useMilkdownCollab({ 22 | editor: loading ? null : get() || null, 23 | websocketUrl: `${WS_URL}/note`, 24 | roomName: noteId || "", 25 | }); 26 | return ; 27 | } 28 | 29 | function MilkdownEditorWrapper() { 30 | return ( 31 | 32 | 33 | 34 | 35 | 36 | ); 37 | } 38 | 39 | export default MilkdownEditorWrapper; 40 | -------------------------------------------------------------------------------- /packages/frontend/src/components/SpaceUsersIndicator.tsx: -------------------------------------------------------------------------------- 1 | import { WebsocketProvider } from "y-websocket"; 2 | 3 | import useYjsSpaceAwarenessStates from "@/hooks/useYjsSpaceAwareness"; 4 | import { useYjsStore } from "@/store/yjs"; 5 | 6 | function SpaceUserAvatar({ color }: { color: string }) { 7 | return ( 8 |
9 |
13 |
14 | ); 15 | } 16 | 17 | export default function SpaceUsersIndicator() { 18 | const { yProvider } = useYjsStore(); 19 | const awareness = (yProvider as WebsocketProvider | undefined)?.awareness; 20 | const { states: userStates } = useYjsSpaceAwarenessStates(awareness); 21 | 22 | return ( 23 |
24 | {userStates.size > 4 && ( 25 | +{userStates.size - 4} 26 | )} 27 |
28 | {[...userStates].slice(0, 4).map(([userId, { color }]) => ( 29 | 30 | ))} 31 |
32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /packages/backend/src/test/mock/space.mock.data.ts: -------------------------------------------------------------------------------- 1 | import { MockSpace } from '../types/performance.types'; 2 | 3 | export const spaceMockData: MockSpace[] = [ 4 | { 5 | id: '69bd78b6-0755-4370-be44-3ab0adab011a', 6 | userId: 'test-user-id', 7 | name: 'test', 8 | edges: JSON.stringify({ 9 | u7c2xras28c: { 10 | from: '69bd78b6-0755-4370-be44-3ab0adab011a', 11 | to: '65ol60chol8', 12 | }, 13 | an5uhqliqpm: { 14 | from: '69bd78b6-0755-4370-be44-3ab0adab011a', 15 | to: 'ousnj3faubc', 16 | }, 17 | }), 18 | nodes: JSON.stringify({ 19 | '69bd78b6-0755-4370-be44-3ab0adab011a': { 20 | id: '69bd78b6-0755-4370-be44-3ab0adab011a', 21 | x: 0, 22 | y: 0, 23 | type: 'head', 24 | name: 'test space', 25 | src: '69bd78b6-0755-4370-be44-3ab0adab011a', 26 | }, 27 | '65ol60chol8': { 28 | id: '65ol60chol8', 29 | type: 'note', 30 | x: 283.50182393227146, 31 | y: -132.99774870089817, 32 | name: 'note', 33 | src: 'c1ddbb14-689a-49ac-a2fc-a14aebb1c4ed', 34 | }, 35 | }), 36 | createdAt: new Date(), 37 | updatedAt: new Date(), 38 | }, 39 | ]; 40 | -------------------------------------------------------------------------------- /packages/frontend/src/components/ui/hover-card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import * as HoverCardPrimitive from "@radix-ui/react-hover-card"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const HoverCard = HoverCardPrimitive.Root; 8 | 9 | const HoverCardTrigger = HoverCardPrimitive.Trigger; 10 | 11 | const HoverCardContent = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 15 | 25 | )); 26 | HoverCardContent.displayName = HoverCardPrimitive.Content.displayName; 27 | 28 | export { HoverCard, HoverCardTrigger, HoverCardContent }; 29 | -------------------------------------------------------------------------------- /packages/frontend/src/api/space.ts: -------------------------------------------------------------------------------- 1 | import { BreadcrumbItem } from "shared/types"; 2 | 3 | import { API_V1_URL, API_V2_URL } from "./constants"; 4 | import http from "./http"; 5 | 6 | type CreateSpaceRequestBody = { 7 | userId: string; 8 | spaceName: string; 9 | parentContextNodeId: string | null; 10 | }; 11 | 12 | type CreateSpaceResponseBody = { 13 | urlPath: string; 14 | }; 15 | 16 | export async function createSpace(body: CreateSpaceRequestBody) { 17 | const response = await http.post( 18 | `${API_V2_URL}/space`, 19 | { body: JSON.stringify(body) }, 20 | ); 21 | return response.data; 22 | } 23 | 24 | type GetBreadcrumbResponseBody = BreadcrumbItem[]; 25 | 26 | export async function getBreadcrumbOfSpace(spaceUrlPath: string) { 27 | const response = await http.get( 28 | `${API_V1_URL}/space/breadcrumb/${spaceUrlPath}`, 29 | ); 30 | return response.data; 31 | } 32 | 33 | export async function updateSpace(id: string, body: CreateSpaceRequestBody) { 34 | const response = await http.put( 35 | `${API_V1_URL}/space/${id}`, 36 | { body: JSON.stringify(body) }, 37 | ); 38 | return response.data; 39 | } 40 | 41 | export async function deleteSpace(spaceId: string) { 42 | const response = await http.delete(`${API_V1_URL}/space/${spaceId}`); 43 | return response; 44 | } 45 | -------------------------------------------------------------------------------- /example/docker-compose.db.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | redis: 5 | image: redis:latest 6 | container_name: redis 7 | command: redis-server --requirepass ${REDIS_PASSWORD} 8 | ports: 9 | - "6379:6379" 10 | volumes: 11 | - redis_data:/data 12 | networks: 13 | - database_network 14 | restart: unless-stopped 15 | environment: 16 | - REDIS_PASSWORD=${REDIS_PASSWORD} 17 | 18 | mongodb: 19 | image: mongo:latest 20 | container_name: mongodb 21 | ports: 22 | - "27017:27017" 23 | volumes: 24 | - mongodb_data:/data/db 25 | networks: 26 | - database_network 27 | restart: unless-stopped 28 | environment: 29 | - MONGO_INITDB_ROOT_USERNAME=${MONGO_USERNAME} 30 | - MONGO_INITDB_ROOT_PASSWORD=${MONGO_PASSWORD} 31 | 32 | mysql: 33 | image: mysql:8 34 | container_name: mysql 35 | ports: 36 | - "3306:3306" 37 | volumes: 38 | - mysql_data:/var/lib/mysql 39 | networks: 40 | - database_network 41 | restart: unless-stopped 42 | environment: 43 | - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} 44 | - MYSQL_DATABASE=${MYSQL_DATABASE} 45 | - MYSQL_USER=${MYSQL_USER} 46 | - MYSQL_PASSWORD=${MYSQL_PASSWORD} 47 | 48 | networks: 49 | database_network: 50 | driver: bridge 51 | 52 | volumes: 53 | redis_data: 54 | mongodb_data: 55 | mysql_data: -------------------------------------------------------------------------------- /packages/backend/src/space/space.validation.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectModel } from '@nestjs/mongoose'; 3 | import { Model } from 'mongoose'; 4 | 5 | import { ERROR_MESSAGES } from '../common/constants/error.message.constants'; 6 | import { MAX_SPACES } from '../common/constants/space.constants'; 7 | import { SpaceDocument } from './space.schema'; 8 | 9 | @Injectable() 10 | export class SpaceValidation { 11 | constructor( 12 | @InjectModel(SpaceDocument.name) 13 | private readonly spaceModel: Model, 14 | ) {} 15 | 16 | async validateSpaceLimit(userId: string) { 17 | const spaceCount = await this.spaceModel.countDocuments({ userId }); 18 | 19 | if (spaceCount >= MAX_SPACES) { 20 | throw new Error(ERROR_MESSAGES.SPACE.LIMIT_EXCEEDED); 21 | } 22 | } 23 | 24 | async validateParentNodeExists(parentContextNodeId: string | null) { 25 | if (parentContextNodeId) { 26 | const space = await this.spaceModel.findOne({ 27 | id: parentContextNodeId, 28 | }); 29 | 30 | if (!space) { 31 | throw new Error(ERROR_MESSAGES.SPACE.PARENT_NOT_FOUND); 32 | } 33 | } 34 | } 35 | 36 | async validateSpaceExists(urlPath: string) { 37 | const space = await this.spaceModel.findOne({ urlPath }); 38 | 39 | if (!space) { 40 | throw new Error(ERROR_MESSAGES.SPACE.NOT_FOUND); 41 | } 42 | 43 | return space; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | import pluginJs from "@eslint/js"; 3 | import prettierConfig from "eslint-config-prettier"; 4 | import prettierPluginRecommended from "eslint-plugin-prettier/recommended"; 5 | import tseslint from "typescript-eslint"; 6 | 7 | /** @see https://www.raulmelo.me/en/blog/migration-eslint-to-flat-config */ 8 | import { FlatCompat } from "@eslint/eslintrc"; 9 | import path from "path"; 10 | import { fileURLToPath } from "url"; 11 | // mimic CommonJS variables -- not needed if using CommonJS 12 | const __filename = fileURLToPath(import.meta.url); 13 | const __dirname = path.dirname(__filename); 14 | const compat = new FlatCompat({ 15 | baseDirectory: __dirname, // optional; default: process.cwd() 16 | resolvePluginsRelativeTo: __dirname, // optional 17 | }); 18 | 19 | /** @type {import('eslint').Linter.Config[]} */ 20 | export default [ 21 | { files: ["**/*.{js,mjs,cjs,ts}"] }, 22 | pluginJs.configs.recommended, 23 | ...tseslint.configs.recommended, 24 | ...compat.extends("airbnb-base"), 25 | prettierConfig, 26 | prettierPluginRecommended, 27 | { 28 | rules: { 29 | "import/no-extraneous-dependencies": [ 30 | "warn", 31 | { 32 | devDependencies: [ 33 | "**/*.config.{mts,ts,mjs,js}", 34 | "**/storybook/**", 35 | "**/stories/**", 36 | "**/*.stories.{ts,tsx,js,jsx}", 37 | "**/*.{spec,test}.{ts,tsx,js,jsx}", 38 | ], 39 | }, 40 | ], 41 | }, 42 | }, 43 | ]; 44 | -------------------------------------------------------------------------------- /packages/frontend/src/components/space/SpaceNode.stories.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { Layer, Stage } from "react-konva"; 3 | 4 | import type { Meta, StoryObj } from "@storybook/react"; 5 | 6 | import SpaceNode from "./SpaceNode.tsx"; 7 | 8 | export default { 9 | component: SpaceNode, 10 | tags: ["autodocs"], 11 | decorators: [ 12 | (Story, { canvasElement }) => { 13 | // TODO: Konva Node를 위한 decorator 별도로 분리 必 14 | const [size, setSize] = useState(() => ({ 15 | width: Math.max(canvasElement.clientWidth, 256), 16 | height: Math.max(canvasElement.clientHeight, 256), 17 | })); 18 | 19 | const { width, height } = size; 20 | 21 | useEffect(() => { 22 | const observer = new ResizeObserver((entries) => { 23 | entries.forEach((entry) => { 24 | const { width, height } = entry.contentRect; 25 | setSize({ width, height }); 26 | }); 27 | }); 28 | 29 | observer.observe(canvasElement); 30 | return () => observer.unobserve(canvasElement); 31 | }, [canvasElement]); 32 | 33 | return ( 34 | 35 | 36 | 37 | 38 | 39 | ); 40 | }, 41 | ], 42 | } satisfies Meta; 43 | 44 | export const Normal: StoryObj = { 45 | args: { 46 | label: "HelloWorld", 47 | x: 0, 48 | y: 0, 49 | }, 50 | }; 51 | -------------------------------------------------------------------------------- /packages/frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine AS builder 2 | WORKDIR /app 3 | RUN apk add --no-cache python3 make g++ && npm install -g pnpm 4 | COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ 5 | COPY ./tsconfig.json ./ 6 | COPY ./packages/shared/ ./packages/shared/ 7 | COPY ./packages/frontend/ ./packages/frontend/ 8 | 9 | RUN HUSKY=0 pnpm install --no-frozen-lockfile 10 | COPY ./packages/frontend/src ./packages/frontend/src 11 | COPY ./packages/frontend/public ./packages/frontend/public 12 | COPY ./packages/frontend/index.html ./packages/frontend 13 | COPY ./packages/frontend/vite.config.ts ./packages/frontend 14 | RUN cd ./packages/frontend && pnpm build 15 | 16 | 17 | FROM nginx:alpine AS production 18 | 19 | RUN echo 'events { worker_connections 1024; }' > /etc/nginx/nginx.conf && \ 20 | echo 'http {' >> /etc/nginx/nginx.conf && \ 21 | echo ' include /etc/nginx/mime.types;' >> /etc/nginx/nginx.conf && \ 22 | echo ' default_type application/octet-stream;' >> /etc/nginx/nginx.conf && \ 23 | echo ' server_tokens off;' >> /etc/nginx/nginx.conf && \ 24 | echo ' access_log off;' >> /etc/nginx/nginx.conf && \ 25 | echo ' error_log stderr crit;' >> /etc/nginx/nginx.conf && \ 26 | echo ' include /etc/nginx/conf.d/*.conf;' >> /etc/nginx/nginx.conf && \ 27 | echo '}' >> /etc/nginx/nginx.conf 28 | 29 | COPY --from=builder /app/packages/frontend/nginx.conf /etc/nginx/conf.d/default.conf 30 | COPY --from=builder /app/packages/frontend/dist /usr/share/nginx/html 31 | 32 | EXPOSE 80 33 | CMD ["nginx", "-g", "daemon off;"] -------------------------------------------------------------------------------- /packages/frontend/src/components/space/SpaceNode.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, useEffect, useRef, useState } from "react"; 2 | import { Circle, Group, Text } from "react-konva"; 3 | 4 | import Konva from "konva"; 5 | 6 | // FIXME: 이런 동작이 많이 필요할 것 같아 별도의 파일로 분리할 것 7 | function TextWithCenteredAnchor(props: Konva.TextConfig) { 8 | const ref = useRef(null); 9 | 10 | const [offsetX, setOffsetX] = useState(undefined); 11 | const [offsetY, setOffsetY] = useState(undefined); 12 | 13 | useEffect(() => { 14 | if (!ref.current || props.offset !== undefined) { 15 | return; 16 | } 17 | 18 | if (props.offsetX === undefined) { 19 | setOffsetX(ref.current.width() / 2); 20 | } 21 | 22 | if (props.offsetY === undefined) { 23 | setOffsetY(ref.current.height() / 2); 24 | } 25 | }, [props]); 26 | 27 | return ; 28 | } 29 | 30 | export interface SpaceNodeProps { 31 | label?: string; 32 | x: number; 33 | y: number; 34 | } 35 | const SpaceNode = forwardRef( 36 | ({ label, x, y }, ref) => { 37 | // TODO: 색상에 대해 정하기, 크기에 대해 정하기 38 | const fillColor = "royalblue"; 39 | const textColor = "white"; 40 | 41 | return ( 42 | 43 | 44 | 45 | 46 | ); 47 | }, 48 | ); 49 | SpaceNode.displayName = "SpaceNode"; 50 | 51 | export default SpaceNode; 52 | -------------------------------------------------------------------------------- /packages/backend/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Logger, Module, OnModuleInit } from '@nestjs/common'; 2 | import { ConfigModule, ConfigService } from '@nestjs/config'; 3 | import { MongooseModule } from '@nestjs/mongoose'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | 6 | import { CollaborativeModule } from './collaborative/collaborative.module'; 7 | import { getMongooseConfig } from './common/config/mongo.config'; 8 | import { getTypeOrmConfig } from './common/config/typeorm.config'; 9 | import { NoteModule } from './note/note.module'; 10 | import { SpaceModule } from './space/space.module'; 11 | import { YjsModule } from './yjs/yjs.module'; 12 | import { TestModule } from './test/test.module'; 13 | 14 | @Module({ 15 | imports: [ 16 | ConfigModule.forRoot({ 17 | isGlobal: true, 18 | }), 19 | MongooseModule.forRootAsync({ 20 | inject: [ConfigService], 21 | useFactory: getMongooseConfig, 22 | }), 23 | TypeOrmModule.forRootAsync({ 24 | inject: [ConfigService], 25 | useFactory: getTypeOrmConfig, 26 | }), 27 | SpaceModule, 28 | YjsModule, 29 | NoteModule, 30 | TestModule, 31 | CollaborativeModule, 32 | ], 33 | }) 34 | export class AppModule implements OnModuleInit { 35 | private readonly logger = new Logger(AppModule.name); 36 | 37 | async onModuleInit(): Promise { 38 | this.logger.debug('Application initialized for debug'); 39 | this.logger.log('Application initialized', { 40 | module: 'AppModule', 41 | environment: process.env.NODE_ENV ?? 'development', 42 | timestamp: new Date().toISOString(), 43 | }); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/frontend/src/components/SpaceShareAlertContent.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useState } from "react"; 2 | 3 | import { CheckIcon, ClipboardCopyIcon } from "lucide-react"; 4 | 5 | import { cn, copyToClipboard } from "@/lib/utils"; 6 | 7 | import { Button } from "./ui/button"; 8 | 9 | export default function SpaceShareAlertContent() { 10 | const [hasCopied, setHasCopied] = useState(false); 11 | const timeoutRef = useRef(null); 12 | 13 | const handleClickCopy = () => { 14 | async function copy() { 15 | await copyToClipboard(window.location.href); 16 | setHasCopied(true); 17 | 18 | if (timeoutRef.current) { 19 | window.clearTimeout(timeoutRef.current); 20 | } 21 | 22 | timeoutRef.current = window.setTimeout(() => { 23 | setHasCopied(false); 24 | }, 2000); 25 | } 26 | 27 | copy(); 28 | }; 29 | 30 | return ( 31 |
32 |
아래 주소를 공유해주세요
33 |
34 |         {window.location.href}
35 |       
36 |
37 | 54 |
55 |
56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /packages/frontend/src/hooks/useMilkdownCollab.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | import { Editor } from "@milkdown/kit/core"; 4 | import { Ctx } from "@milkdown/kit/ctx"; 5 | import { CollabService, collabServiceCtx } from "@milkdown/plugin-collab"; 6 | import { WebsocketProvider } from "y-websocket"; 7 | import * as Y from "yjs"; 8 | 9 | type useMilkdownCollabProps = { 10 | editor: Editor | null; 11 | websocketUrl: string; 12 | roomName: string; 13 | }; 14 | 15 | const template = `# `; 16 | 17 | export default function useMilkdownCollab({ 18 | editor, 19 | websocketUrl, 20 | roomName, 21 | }: useMilkdownCollabProps) { 22 | useEffect(() => { 23 | if (!editor) return undefined; 24 | 25 | const doc = new Y.Doc(); 26 | 27 | const wsProvider = new WebsocketProvider(websocketUrl, roomName, doc, { 28 | connect: true, 29 | }); 30 | 31 | let collabService: CollabService; 32 | 33 | wsProvider.once("synced", async (isSynced: boolean) => { 34 | if (isSynced) { 35 | collabService.applyTemplate(template); 36 | console.log(`Successfully connected: ${wsProvider.url}`); 37 | } 38 | }); 39 | 40 | // NOTE - flushSync가 lifecycle 내에서 발생하는 것을 방지하기 위해 setTimeout으로 묶어서 micro task로 취급되게 함 41 | setTimeout(() => { 42 | editor.action((ctx: Ctx) => { 43 | collabService = ctx.get(collabServiceCtx); 44 | collabService.bindDoc(doc).setAwareness(wsProvider.awareness).connect(); 45 | }); 46 | }); 47 | 48 | return () => { 49 | collabService?.disconnect(); 50 | wsProvider?.disconnect(); 51 | }; 52 | }, [editor, websocketUrl, roomName]); 53 | } 54 | -------------------------------------------------------------------------------- /packages/frontend/src/components/space/InteractionGuide.tsx: -------------------------------------------------------------------------------- 1 | import { InfoIcon } from "lucide-react"; 2 | 3 | import { 4 | HoverCard, 5 | HoverCardContent, 6 | HoverCardTrigger, 7 | } from "@/components/ui/hover-card"; 8 | 9 | const interactions = [ 10 | { 11 | id: "node-drag", 12 | title: "새로운 노드 생성", 13 | description: "노드 드래그", 14 | }, 15 | { 16 | id: "node-move", 17 | title: "노드 이동", 18 | description: "클릭 + 0.5초 이상 홀드", 19 | }, 20 | { 21 | id: "screen-move", 22 | title: "화면 이동", 23 | description: "스페이스 빈 공간 클릭 후 드래그", 24 | }, 25 | { 26 | id: "screen-zoom", 27 | title: "화면 줌", 28 | description: "ctrl + 마우스 휠 또는 트랙패드 제스처", 29 | }, 30 | { 31 | id: "node-edit", 32 | title: "노드 편집", 33 | description: "노드 위에서 우클릭", 34 | }, 35 | { 36 | id: "edge-edit", 37 | title: "간선 편집", 38 | description: "간선 위에서 우클릭", 39 | }, 40 | ]; 41 | 42 | export default function InteractionGuide() { 43 | return ( 44 | 45 | 46 | 47 | 48 | 49 |
50 |

상호작용 가이드 🐝

51 |
    52 | {interactions.map((interaction) => ( 53 |
  • 54 | {interaction.title}:  55 | {interaction.description} 56 |
  • 57 | ))} 58 |
59 |
60 |
61 |
62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /docker-compose.prod.yml: -------------------------------------------------------------------------------- 1 | services: 2 | db-healthcheck: 3 | image: curlimages/curl:latest 4 | container_name: db-healthcheck 5 | command: > 6 | /bin/sh -c " 7 | curl -f telnet://${REDIS_HOST}:${REDIS_PORT} || exit 1; 8 | curl -f telnet://${MYSQL_HOST}:${MYSQL_PORT} || exit 1; 9 | curl -f telnet://${MONGO_HOST}:27017 || exit 1; 10 | " 11 | networks: 12 | - app-network 13 | healthcheck: 14 | test: ["CMD", "/bin/sh", "-c", "exit 0"] 15 | interval: 10s 16 | timeout: 5s 17 | retries: 3 18 | 19 | backend: 20 | container_name: backend 21 | ports: 22 | - "3000:3000" 23 | build: 24 | target: production 25 | environment: 26 | # 배포 환경 세팅 27 | - NODE_ENV=production 28 | 29 | # MySQL 세팅 30 | - MYSQL_HOST=${MYSQL_HOST} 31 | - MYSQL_PORT=${MYSQL_PORT} 32 | - MYSQL_USER=${MYSQL_USER} 33 | - MYSQL_PASSWORD=${MYSQL_PASSWORD} 34 | - MYSQL_DATABASE=${MYSQL_DATABASE} 35 | 36 | # Mongo 세팅 37 | - MONGO_HOST=${MONGO_HOST} 38 | - MONGO_USER=${MONGO_USER} 39 | - MONGO_PASSWORD=${MONGO_PASSWORD} 40 | - MONGO_DB=${MONGO_DB} 41 | - LOG_LEVEL=${LOG_LEVEL} 42 | networks: 43 | - app-network 44 | 45 | frontend: 46 | container_name: frontend 47 | depends_on: 48 | backend: 49 | condition: service_started 50 | ports: 51 | - "80:80" 52 | environment: 53 | - NODE_ENV=production 54 | - BACKEND_URL=http://backend:3000 55 | extra_hosts: 56 | - "db-host:${DATABASE_HOST}" 57 | networks: 58 | - app-network 59 | 60 | networks: 61 | app-network: 62 | driver: bridge 63 | -------------------------------------------------------------------------------- /packages/frontend/src/components/note/Block.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | 3 | import { BlockProvider } from "@milkdown/kit/plugin/block"; 4 | import { useInstance } from "@milkdown/react"; 5 | 6 | export const BlockView = () => { 7 | const ref = useRef(null); 8 | const tooltipProvider = useRef(); 9 | 10 | const [loading, get] = useInstance(); 11 | 12 | useEffect(() => { 13 | const div = ref.current; 14 | if (loading || !div) return undefined; 15 | 16 | const editor = get(); 17 | if (!editor) return undefined; 18 | 19 | tooltipProvider.current = new BlockProvider({ 20 | ctx: editor.ctx, 21 | content: div, 22 | }); 23 | tooltipProvider.current?.update(); 24 | 25 | return () => { 26 | tooltipProvider.current?.destroy(); 27 | }; 28 | }, [loading, get]); 29 | 30 | return ( 31 |
35 | 43 | 50 | 51 |
52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /packages/frontend/src/components/space/SpacePageHeader.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | import { BreadcrumbItem } from "shared/types"; 4 | 5 | import { getBreadcrumbOfSpace } from "@/api/space"; 6 | import { prompt } from "@/lib/prompt-dialog"; 7 | 8 | import SpaceBreadcrumb from "../SpaceBreadcrumb"; 9 | import SpaceShareAlertContent from "../SpaceShareAlertContent"; 10 | import SpaceUsersIndicator from "../SpaceUsersIndicator"; 11 | import { Button } from "../ui/button"; 12 | 13 | type SpacePageHeaderProps = { 14 | spaceId: string; 15 | }; 16 | 17 | export default function SpacePageHeader({ spaceId }: SpacePageHeaderProps) { 18 | const [spacePaths, setSpacePaths] = useState(null); 19 | 20 | useEffect(() => { 21 | async function fetchSpacePaths() { 22 | const data = await getBreadcrumbOfSpace(spaceId); 23 | setSpacePaths(data); 24 | } 25 | 26 | fetchSpacePaths(); 27 | }, [spaceId]); 28 | 29 | return ( 30 |
31 |
32 |
33 | {spacePaths && } 34 |
35 |
36 | 37 | 46 |
47 |
48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /packages/frontend/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import reactHooks from "eslint-plugin-react-hooks"; 3 | import reactRefresh from "eslint-plugin-react-refresh"; 4 | import storybook from "eslint-plugin-storybook"; 5 | import globals from "globals"; 6 | import tseslint from "typescript-eslint"; 7 | 8 | import rootEsLint from "../../eslint.config.mjs"; 9 | 10 | export default tseslint.config( 11 | { ignores: ["dist"] }, 12 | { 13 | extends: [ 14 | js.configs.recommended, 15 | ...tseslint.configs.recommended, 16 | ...rootEsLint, 17 | ], 18 | files: ["**/*.{ts,tsx}"], 19 | languageOptions: { 20 | ecmaVersion: 2020, 21 | globals: globals.browser, 22 | }, 23 | plugins: { 24 | "react-hooks": reactHooks, 25 | "react-refresh": reactRefresh, 26 | }, 27 | settings: { 28 | "import/resolver": { 29 | typescript: { 30 | project: [ 31 | "./packages/frontend/tsconfig.app.json", 32 | "./packages/frontend/tsconfig.node.json", 33 | ], 34 | }, 35 | }, 36 | }, 37 | rules: { 38 | ...reactHooks.configs.recommended.rules, 39 | "no-shadow": "off", 40 | "import/no-absolute-path": "warn", 41 | "import/no-unresolved": "warn", 42 | "react-refresh/only-export-components": [ 43 | "warn", 44 | { allowConstantExport: true }, 45 | ], 46 | "import/prefer-default-export": "off", 47 | "import/extensions": "off", 48 | }, 49 | }, 50 | { 51 | extends: storybook.configs["flat/recommended"], 52 | files: ["**/*.stories.{ts,tsx,js,jsx}"], 53 | plugins: { 54 | storybook, 55 | }, 56 | rules: { 57 | "storybook/story-exports": "error", 58 | }, 59 | }, 60 | ); 61 | -------------------------------------------------------------------------------- /packages/frontend/src/lib/milkdown-plugin-placeholder.ts: -------------------------------------------------------------------------------- 1 | import { InitReady, prosePluginsCtx } from "@milkdown/kit/core"; 2 | import type { MilkdownPlugin, TimerType } from "@milkdown/kit/ctx"; 3 | import { createSlice, createTimer } from "@milkdown/kit/ctx"; 4 | import { Plugin, PluginKey } from "@milkdown/kit/prose/state"; 5 | import type { EditorView } from "@milkdown/kit/prose/view"; 6 | 7 | export const placeholderCtx = createSlice("Type something...", "placeholder"); 8 | export const placeholderTimerCtx = createSlice( 9 | [] as TimerType[], 10 | "editorStateTimer", 11 | ); 12 | export const PlaceholderReady = createTimer("PlaceholderReady"); 13 | 14 | const key = new PluginKey("MILKDOWN_PLACEHOLDER"); 15 | 16 | export const placeholder: MilkdownPlugin = (ctx) => { 17 | ctx 18 | .inject(placeholderCtx) 19 | .inject(placeholderTimerCtx, [InitReady]) 20 | .record(PlaceholderReady); 21 | 22 | return async () => { 23 | await ctx.waitTimers(placeholderTimerCtx); 24 | 25 | const prosePlugins = ctx.get(prosePluginsCtx); 26 | 27 | const update = (view: EditorView) => { 28 | const placeholder = ctx.get(placeholderCtx); 29 | const { doc } = view.state; 30 | if ( 31 | view.editable && 32 | doc.childCount === 1 && 33 | doc.firstChild?.isTextblock && 34 | doc.firstChild?.content.size === 0 35 | ) { 36 | view.dom.setAttribute("data-placeholder", placeholder); 37 | } else { 38 | view.dom.removeAttribute("data-placeholder"); 39 | } 40 | }; 41 | 42 | const plugins = [ 43 | ...prosePlugins, 44 | new Plugin({ 45 | key, 46 | view(view) { 47 | update(view); 48 | return { update }; 49 | }, 50 | }), 51 | ]; 52 | 53 | ctx.set(prosePluginsCtx, plugins); 54 | ctx.done(PlaceholderReady); 55 | }; 56 | }; 57 | -------------------------------------------------------------------------------- /packages/frontend/src/hooks/yjs/useY.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useSyncExternalStore } from "react"; 2 | 3 | import * as Y from "yjs"; 4 | 5 | // FIXME: Implement equalsDeep (현재는 임시로 구현해놓음) 6 | function equalsDeep(a: T, b: T) { 7 | if (a === b) { 8 | return true; 9 | } 10 | 11 | if (typeof a !== "object" || typeof b !== "object") { 12 | return false; 13 | } 14 | 15 | if (Object.keys(a).length !== Object.keys(b).length) { 16 | return false; 17 | } 18 | 19 | // eslint-disable-next-line no-restricted-syntax 20 | for (const key in a) { 21 | if ( 22 | Object.prototype.hasOwnProperty.call(b, key) && 23 | !equalsDeep(a[key] as T, b[key] as T) 24 | ) { 25 | return false; 26 | } 27 | } 28 | 29 | return true; 30 | } 31 | 32 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 33 | export default function useY | undefined>( 34 | yData: T, 35 | ): unknown { 36 | const currentDataRef = useRef(undefined); 37 | 38 | const subscribe = (onStoreChanged: () => void) => { 39 | const callback = () => { 40 | onStoreChanged(); 41 | }; 42 | 43 | if (yData) { 44 | yData.observeDeep(callback); 45 | return () => yData.unobserveDeep(callback); 46 | } 47 | 48 | return () => {}; 49 | }; 50 | 51 | const snapshot = () => { 52 | const json = yData?.toJSON(); 53 | 54 | // NOTE: reference 비교를 위해서 별도로 체크를 해야 하는 것인가 55 | if (equalsDeep(currentDataRef.current, json)) { 56 | return currentDataRef.current; 57 | } 58 | 59 | currentDataRef.current = json; 60 | return currentDataRef.current; 61 | }; 62 | 63 | const initialSnapshot = () => yData?.toJSON(); 64 | 65 | const currentSnapshot = useSyncExternalStore( 66 | subscribe, 67 | snapshot, 68 | initialSnapshot, 69 | ); 70 | 71 | return currentSnapshot; 72 | } 73 | -------------------------------------------------------------------------------- /packages/backend/src/env.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'y-websocket/bin/utils' { 2 | export function setPersistence( 3 | persistence_: { 4 | bindState: (arg0: string, arg1: WSSharedDoc) => void; 5 | writeState: (arg0: string, arg1: WSSharedDoc) => Promise; 6 | provider: any; 7 | } | null, 8 | ): void; 9 | export function getPersistence(): null | { 10 | bindState: (arg0: string, arg1: WSSharedDoc) => void; 11 | writeState: (arg0: string, arg1: WSSharedDoc) => Promise; 12 | } | null; 13 | export function setContentInitializor( 14 | f: (ydoc: Y.Doc) => Promise, 15 | ): void; 16 | export function setupWSConnection( 17 | conn: import('ws').WebSocket, 18 | req: import('http').IncomingMessage, 19 | { docName, gc }?: any, 20 | ): void; 21 | export class WSSharedDoc extends Y.Doc { 22 | /** 23 | * @param {string} name 24 | */ 25 | constructor(name: string); 26 | name: string; 27 | /** 28 | * Maps from conn to set of controlled user ids. Delete all user ids from awareness when this conn is closed 29 | * @type {Map>} 30 | */ 31 | conns: Map>; 32 | /** 33 | * @type {awarenessProtocol.Awareness} 34 | */ 35 | awareness: awarenessProtocol.Awareness; 36 | whenInitialized: Promise; 37 | } 38 | /** 39 | * @type {Map} 40 | */ 41 | export const docs: Map; 42 | import Y = require('yjs'); 43 | /** 44 | * Gets a Y.Doc by name, whether in memory or on disk 45 | * 46 | * @param {string} docname - the name of the Y.Doc to find or create 47 | * @param {boolean} gc - whether to allow gc on the doc (applies only when created) 48 | * @return {WSSharedDoc} 49 | */ 50 | export function getYDoc(docname: string, gc?: boolean): WSSharedDoc; 51 | import awarenessProtocol = require('y-protocols/awareness'); 52 | } 53 | -------------------------------------------------------------------------------- /packages/frontend/src/hooks/yjs/useYjsConnection.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | import { WebsocketProvider } from "y-websocket"; 4 | import * as Y from "yjs"; 5 | 6 | import { WS_URL } from "@/api/constants"; 7 | import { generateUserColor } from "@/lib/utils"; 8 | 9 | export default function useYjsConnection(docName: string) { 10 | const [status, setStatus] = useState< 11 | "connecting" | "connected" | "disconnected" 12 | >("disconnected"); 13 | const [error, setError] = useState(); 14 | const [yDoc, setYDoc] = useState(); 15 | const [yProvider, setYProvider] = useState(); 16 | 17 | useEffect(() => { 18 | setStatus("connecting"); 19 | 20 | const doc = new Y.Doc(); 21 | const provider = new WebsocketProvider(`${WS_URL}/space`, docName, doc); 22 | 23 | setYDoc(doc); 24 | setYProvider(provider); 25 | 26 | const { awareness } = provider; 27 | 28 | provider.on( 29 | "status", 30 | (event: { status: "connected" | "connecting" | "disconnected" }) => { 31 | if (event.status === "connected") { 32 | awareness.setLocalStateField("color", generateUserColor()); 33 | } 34 | setStatus(event.status); 35 | }, 36 | ); 37 | 38 | provider.once("connection-close", (event: CloseEvent) => { 39 | if (event.code === 1008) { 40 | provider.shouldConnect = false; 41 | setError(new Error("찾을 수 없거나 접근할 수 없는 스페이스예요.")); 42 | } 43 | }); 44 | 45 | return () => { 46 | if (provider.bcconnected || provider.wsconnected) { 47 | provider.disconnect(); 48 | provider.destroy(); 49 | } 50 | setYDoc(undefined); 51 | setYProvider(undefined); 52 | setError(undefined); 53 | setStatus("disconnected"); 54 | }; 55 | }, [docName]); 56 | 57 | return { status, error, yProvider, yDoc, setYProvider, setYDoc }; 58 | } 59 | -------------------------------------------------------------------------------- /.github/workflows/DEPLOY.yml: -------------------------------------------------------------------------------- 1 | name: deploy 2 | on: 3 | push: 4 | branches: [dev] 5 | jobs: 6 | deploy: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: 코드 체크아웃 10 | uses: actions/checkout@v3 11 | 12 | - name: 타임스탬프 생성 13 | id: timestamp 14 | run: echo "timestamp=$(date +'%Y%m%d%H%M%S')" >> $GITHUB_OUTPUT 15 | 16 | - name: SSH로 서버 접속 및 배포 17 | uses: appleboy/ssh-action@v0.1.6 18 | env: 19 | ENV_FILE_CONTENTS: ${{ secrets.ENV_FILE_CONTENTS }} 20 | with: 21 | host: ${{ secrets.SERVER_HOST }} 22 | username: ${{ secrets.SERVER_USER }} 23 | key: ${{ secrets.SSH_KEY }} 24 | port: ${{ secrets.SERVER_SSH_PORT }} 25 | envs: ENV_FILE_CONTENTS 26 | script: | 27 | # 작업 디렉토리 생성 및 이동 28 | DEPLOY_DIR="/home/${{ secrets.SERVER_USER }}/deploy" 29 | TIMESTAMP="${{ steps.timestamp.outputs.timestamp }}" 30 | RELEASE_DIR="$DEPLOY_DIR/releases/$TIMESTAMP" 31 | 32 | mkdir -p $RELEASE_DIR 33 | cd $RELEASE_DIR 34 | 35 | # 코드 복제 36 | git clone -b dev https://github.com/boostcampwm-2024/web29-honeyflow.git . 37 | 38 | # 환경변수 파일 생성 39 | echo "$ENV_FILE_CONTENTS" > .env 40 | 41 | # 이전 컨테이너 정리 42 | docker stop db-healthcheck || true && docker rm db-healthcheck || true 43 | docker stop backend || true && docker rm backend || true 44 | docker stop frontend || true && docker rm frontend || true 45 | 46 | # 새 컨테이너 실행 47 | sudo docker compose -f docker-compose.yml -f docker-compose.prod.yml pull 48 | sudo docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d 49 | 50 | # 이전 배포 정리 (최근 3개만 유지) 51 | cd $DEPLOY_DIR/releases 52 | ls -t | tail -n +4 | xargs -I {} rm -rf {} 53 | 54 | # 사용하지 않는 Docker 이미지 정리 55 | sudo docker image prune -af 56 | -------------------------------------------------------------------------------- /packages/backend/src/test/test.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | import { InjectModel } from '@nestjs/mongoose'; 5 | import { Model } from 'mongoose'; 6 | import { Space as SpaceEntity } from './space.test.entity'; 7 | import { Note as NoteEntity } from './note.test.entity'; 8 | import { SpaceDocument } from 'src/space/space.schema'; 9 | import { NoteDocument } from 'src/note/note.schema'; 10 | 11 | @Injectable() 12 | export class TestService { 13 | constructor( 14 | @InjectRepository(SpaceEntity) 15 | private spaceRepository: Repository, 16 | @InjectRepository(NoteEntity) 17 | private noteRepository: Repository, 18 | @InjectModel(SpaceDocument.name) 19 | private spaceModel: Model, 20 | @InjectModel(NoteDocument.name) 21 | private noteModel: Model, 22 | ) {} 23 | 24 | async findSpaceByIdSQL(id: string) { 25 | return this.spaceRepository.findOne({ where: { id } }); 26 | } 27 | 28 | async findNoteByIdSQL(id: string) { 29 | return this.noteRepository.findOne({ where: { id } }); 30 | } 31 | 32 | async createSpaceSQL(data: any) { 33 | const space = this.spaceRepository.create(data); 34 | return this.spaceRepository.save(space); 35 | } 36 | 37 | async createNoteSQL(data: any) { 38 | const note = this.noteRepository.create(data); 39 | return this.noteRepository.save(note); 40 | } 41 | 42 | async findSpaceByIdMongo(id: string) { 43 | return this.spaceModel.findOne({ id }).exec(); 44 | } 45 | 46 | async findNoteByIdMongo(id: string) { 47 | return this.noteModel.findOne({ id }).exec(); 48 | } 49 | 50 | async createSpaceMongo(data: any) { 51 | const space = new this.spaceModel(data); 52 | return space.save(); 53 | } 54 | 55 | async createNoteMongo(data: any) { 56 | const note = new this.noteModel(data); 57 | return note.save(); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /packages/frontend/src/api/http.ts: -------------------------------------------------------------------------------- 1 | // TODO: 에러 타입 핸들링 2 | class HttpResponseError extends Error { 3 | constructor(response: Response) { 4 | super(`HTTP error: ${response.status} ${response.statusText}`); 5 | } 6 | } 7 | 8 | type HttpResponse = { 9 | data: T; 10 | status: number; 11 | statusText: string; 12 | headers: Record; 13 | config: RequestInit; 14 | request: Request; 15 | }; 16 | 17 | async function http( 18 | url: string, 19 | config: RequestInit, 20 | ): Promise> { 21 | const request = new Request(url, { 22 | ...config, 23 | headers: { 24 | "Content-Type": "application/json", 25 | ...config.headers, 26 | }, 27 | }); 28 | const response = await fetch(request); 29 | 30 | if (!response.ok) { 31 | throw new HttpResponseError(response); 32 | } 33 | 34 | // json으로 한정 35 | const data = config.method === "DELETE" ? null : await response.json(); 36 | 37 | return { 38 | data, 39 | status: response.status, 40 | statusText: response.statusText, 41 | headers: Object.fromEntries(response.headers.entries()), 42 | config, 43 | request, 44 | }; 45 | } 46 | 47 | http.get = function (url: string, config?: Omit) { 48 | return http(url, { ...config, method: "GET" }); 49 | }; 50 | http.post = function (url: string, config?: Omit) { 51 | return http(url, { ...config, method: "POST" }); 52 | }; 53 | http.put = function (url: string, config?: Omit) { 54 | return http(url, { ...config, method: "PUT" }); 55 | }; 56 | http.patch = function (url: string, config?: Omit) { 57 | return http(url, { ...config, method: "PATCH" }); 58 | }; 59 | http.delete = function (url: string, config?: Omit) { 60 | return http(url, { ...config, method: "DELETE" }); 61 | }; 62 | 63 | export type { HttpResponse }; 64 | 65 | export { HttpResponseError }; 66 | 67 | export default http; 68 | -------------------------------------------------------------------------------- /packages/backend/src/main.ts: -------------------------------------------------------------------------------- 1 | import { Logger, VersioningType } from '@nestjs/common'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { WsAdapter } from '@nestjs/platform-ws'; 4 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; 5 | import type { INestApplication } from '@nestjs/common'; 6 | 7 | import { AppModule } from './app.module'; 8 | import { AllExceptionsFilter } from './common/filters/all-exceptions.filter'; 9 | 10 | const ALLOWED_ORIGINS = [ 11 | 'http://www.honeyflow.life', 12 | 'https://www.honeyflow.life', 13 | 'http://localhost', 14 | ] as string[]; 15 | 16 | function configureGlobalSettings(app: INestApplication) { 17 | app.setGlobalPrefix('/api'); 18 | app.useGlobalFilters(new AllExceptionsFilter()); 19 | app.useWebSocketAdapter(new WsAdapter(app)); 20 | app.enableCors({ 21 | origin: (origin, callback) => { 22 | if (!origin || ALLOWED_ORIGINS.includes(origin)) { 23 | callback(null, origin); 24 | } else { 25 | callback(new Error('Not allowed by CORS')); 26 | } 27 | }, 28 | methods: 'GET, POST, PUT, DELETE', 29 | allowedHeaders: 'Content-Type, Authorization', 30 | credentials: true, 31 | }); 32 | app.enableVersioning({ 33 | type: VersioningType.URI, 34 | defaultVersion: '1', 35 | }); 36 | } 37 | 38 | function configureSwagger(app: INestApplication) { 39 | const config = new DocumentBuilder() 40 | .setTitle('API 문서') 41 | .setDescription('API 설명') 42 | .setVersion('2.0') 43 | .build(); 44 | 45 | const document = SwaggerModule.createDocument(app, config); 46 | SwaggerModule.setup('api-docs', app, document); 47 | } 48 | 49 | async function bootstrap() { 50 | const app = await NestFactory.create(AppModule); 51 | const logger = new Logger('Bootstrap'); 52 | 53 | configureGlobalSettings(app); 54 | configureSwagger(app); 55 | 56 | const PORT = process.env.PORT ?? 3000; 57 | await app.listen(PORT); 58 | 59 | logger.log(`Honeyflow started on port ${PORT}`); 60 | } 61 | 62 | bootstrap(); 63 | -------------------------------------------------------------------------------- /packages/frontend/src/hooks/yjs/useYjsAwareness.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo, useRef, useSyncExternalStore } from "react"; 2 | 3 | import { WebsocketProvider } from "y-websocket"; 4 | 5 | type Awareness = WebsocketProvider["awareness"]; // 여기선 어쩔 수 없이 WebSocketProvider임을 특정 6 | 7 | export default function useYjsAwarenessStates< 8 | S extends { [x: string]: unknown }, 9 | >(awareness?: Awareness) { 10 | const storeRef = useRef<{ 11 | states: Map; 12 | localState: S | undefined; 13 | }>(); 14 | 15 | if (!storeRef.current) { 16 | storeRef.current = { 17 | states: (awareness?.states as Map) || new Map(), 18 | localState: (awareness?.getLocalState() as S) || undefined, 19 | }; 20 | } 21 | 22 | const { setLocalState, setLocalStateField } = useMemo(() => { 23 | const setLocalState = (state: S) => { 24 | awareness?.setLocalState(state); 25 | }; 26 | 27 | const setLocalStateField = (field: K, value: S[K]) => { 28 | awareness?.setLocalStateField(field as string, value); 29 | }; 30 | 31 | return { setLocalState, setLocalStateField }; 32 | }, [awareness]); 33 | 34 | const subscribe = useCallback( 35 | (onStoreChange: () => void) => { 36 | const handleOnChange = () => { 37 | const states = new Map(awareness?.states as Map); 38 | const localState = awareness?.getLocalState() as S; 39 | 40 | storeRef.current = { states, localState }; 41 | onStoreChange(); 42 | }; 43 | 44 | awareness?.on("change", handleOnChange); 45 | return () => awareness?.off("change", handleOnChange); 46 | }, 47 | [awareness], 48 | ); 49 | 50 | const getSnapshot = useCallback(() => { 51 | if (!storeRef.current) { 52 | return { 53 | states: new Map(), 54 | localState: undefined, 55 | }; 56 | } 57 | 58 | return storeRef.current; 59 | }, []); 60 | 61 | const { states, localState } = useSyncExternalStore( 62 | subscribe, 63 | getSnapshot, 64 | getSnapshot, 65 | ); 66 | 67 | return { localState, states, setLocalState, setLocalStateField }; 68 | } 69 | -------------------------------------------------------------------------------- /packages/frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | import tailwindcssTypography from "@tailwindcss/typography"; 2 | import tailwindcssAnimate from "tailwindcss-animate"; 3 | 4 | /** @type {import('tailwindcss').Config} */ 5 | export default { 6 | darkMode: ["class"], 7 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], 8 | theme: { 9 | extend: { 10 | borderRadius: { 11 | lg: "var(--radius)", 12 | md: "calc(var(--radius) - 2px)", 13 | sm: "calc(var(--radius) - 4px)", 14 | }, 15 | colors: { 16 | background: "hsl(var(--background))", 17 | foreground: "hsl(var(--foreground))", 18 | card: { 19 | DEFAULT: "hsl(var(--card))", 20 | foreground: "hsl(var(--card-foreground))", 21 | }, 22 | popover: { 23 | DEFAULT: "hsl(var(--popover))", 24 | foreground: "hsl(var(--popover-foreground))", 25 | }, 26 | primary: { 27 | DEFAULT: "hsl(var(--primary))", 28 | foreground: "hsl(var(--primary-foreground))", 29 | }, 30 | secondary: { 31 | DEFAULT: "hsl(var(--secondary))", 32 | foreground: "hsl(var(--secondary-foreground))", 33 | }, 34 | muted: { 35 | DEFAULT: "hsl(var(--muted))", 36 | foreground: "hsl(var(--muted-foreground))", 37 | }, 38 | accent: { 39 | DEFAULT: "hsl(var(--accent))", 40 | foreground: "hsl(var(--accent-foreground))", 41 | }, 42 | destructive: { 43 | DEFAULT: "hsl(var(--destructive))", 44 | foreground: "hsl(var(--destructive-foreground))", 45 | }, 46 | border: "hsl(var(--border))", 47 | input: "hsl(var(--input))", 48 | ring: "hsl(var(--ring))", 49 | chart: { 50 | 1: "hsl(var(--chart-1))", 51 | 2: "hsl(var(--chart-2))", 52 | 3: "hsl(var(--chart-3))", 53 | 4: "hsl(var(--chart-4))", 54 | 5: "hsl(var(--chart-5))", 55 | }, 56 | }, 57 | backgroundImage: { 58 | home: "url('/home-bg.svg')", 59 | }, 60 | }, 61 | }, 62 | plugins: [tailwindcssAnimate, tailwindcssTypography], 63 | }; 64 | -------------------------------------------------------------------------------- /packages/frontend/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { Slot } from "@radix-ui/react-slot"; 4 | import { type VariantProps, cva } from "class-variance-authority"; 5 | 6 | import { cn } from "@/lib/utils.ts"; 7 | 8 | export default {}; 9 | const buttonVariants = cva( 10 | "inline-flex items-center justify-center gap-2 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 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 11 | { 12 | variants: { 13 | variant: { 14 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 15 | destructive: 16 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 17 | outline: 18 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 19 | secondary: 20 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 21 | ghost: "hover:bg-accent hover:text-accent-foreground", 22 | link: "text-primary underline-offset-4 hover:underline", 23 | }, 24 | size: { 25 | default: "h-10 px-4 py-2", 26 | sm: "h-9 rounded-md px-3", 27 | lg: "h-11 rounded-md px-8", 28 | icon: "h-10 w-10", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "default", 33 | size: "default", 34 | }, 35 | }, 36 | ); 37 | 38 | export interface ButtonProps 39 | extends React.ButtonHTMLAttributes, 40 | VariantProps { 41 | asChild?: boolean; 42 | } 43 | 44 | const Button = React.forwardRef( 45 | ({ className, variant, size, asChild = false, ...props }, ref) => { 46 | const Comp = asChild ? Slot : "button"; 47 | return ( 48 | 53 | ); 54 | }, 55 | ); 56 | Button.displayName = "Button"; 57 | 58 | export { Button, buttonVariants }; 59 | -------------------------------------------------------------------------------- /.github/workflows/REVIEW_REQUEST_ALERT.yml: -------------------------------------------------------------------------------- 1 | name: BeeBot 2 | on: 3 | pull_request: 4 | types: [review_request_removed] 5 | jobs: 6 | notify_automatically_assigned_review_request: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | 11 | - name: 알림 전송 이력이 있는지 확인 12 | id: check_notification 13 | uses: actions/cache@v3 14 | with: 15 | path: .notifications 16 | key: notifications-${{ github.event.pull_request.number }} 17 | 18 | - name: 리뷰어 목록 가져오기 19 | if: steps.check_notification.outputs.cache-hit != 'true' 20 | id: reviewers 21 | uses: actions/github-script@v6 22 | with: 23 | script: | 24 | const fs = require('fs'); 25 | const workers = JSON.parse(fs.readFileSync('.github/workflows/reviewers.json')); 26 | const mention = context.payload.pull_request.requested_reviewers.map((user) => { 27 | const login = user.login; 28 | const mappedValue = workers[login]; 29 | return mappedValue ? `<@${mappedValue}>` : `No mapping found for ${login}`; 30 | }); 31 | return mention.join(', '); 32 | result-encoding: string 33 | 34 | - name: 슬랙 알림 전송 35 | if: steps.check_notification.outputs.cache-hit != 'true' 36 | uses: slackapi/slack-github-action@v1.24.0 37 | with: 38 | channel-id: ${{ secrets.SLACK_CHANNEL }} 39 | payload: | 40 | { 41 | "text": "[리뷰 요청] 새로운 PR이 등록되었습니다!", 42 | "blocks": [ 43 | { 44 | "type": "section", 45 | "text": { 46 | "type": "mrkdwn", 47 | "text": "[리뷰 요청] 새로운 PR이 등록되었습니다!\n • 제목: ${{ github.event.pull_request.title }}\n • 리뷰어: ${{ steps.reviewers.outputs.result }} \n • 링크: <${{ github.event.pull_request.html_url }}|리뷰하러 가기>" 48 | } 49 | } 50 | ] 51 | } 52 | env: 53 | SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} 54 | 55 | - name: 알림 전송 이력 생성 56 | if: steps.check_notification.outputs.cache-hit != 'true' 57 | run: | 58 | mkdir -p .notifications 59 | touch .notifications/sent 60 | -------------------------------------------------------------------------------- /packages/frontend/src/pages/Space.tsx: -------------------------------------------------------------------------------- 1 | import { useRef } from "react"; 2 | import { useNavigate, useParams } from "react-router-dom"; 3 | 4 | import { CircleDashedIcon, MoveLeftIcon } from "lucide-react"; 5 | 6 | import ErrorSection from "@/components/ErrorSection"; 7 | import InteractionGuide from "@/components/space/InteractionGuide"; 8 | import SpacePageHeader from "@/components/space/SpacePageHeader"; 9 | import SpaceView from "@/components/space/SpaceView"; 10 | import { Button } from "@/components/ui/button"; 11 | import useYjsConnection from "@/hooks/yjs/useYjsConnection"; 12 | import { YjsStoreProvider } from "@/store/yjs"; 13 | 14 | interface SpacePageParams extends Record { 15 | spaceId?: string; 16 | } 17 | 18 | export default function SpacePage() { 19 | const navigate = useNavigate(); 20 | const { spaceId } = useParams(); 21 | 22 | if (!spaceId) { 23 | throw new Error(""); 24 | } 25 | 26 | const { error, status, yDoc, yProvider, setYDoc, setYProvider } = 27 | useYjsConnection(spaceId); 28 | const containerRef = useRef(null); 29 | 30 | if (error) { 31 | return ( 32 | ( 35 | <> 36 | 40 | 41 | )} 42 | /> 43 | ); 44 | } 45 | 46 | return ( 47 | 48 |
49 | {status === "connecting" ? ( 50 |
51 | 52 |
53 | ) : ( 54 | 55 | )} 56 | 57 |
63 | 64 |
65 |
66 |
67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /packages/frontend/src/components/PointerLayer.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from "react"; 2 | import { Layer } from "react-konva"; 3 | 4 | import Konva from "konva"; 5 | import { WebsocketProvider } from "y-websocket"; 6 | 7 | import useYjsSpaceAwarenessStates from "@/hooks/useYjsSpaceAwareness"; 8 | import { throttle } from "@/lib/utils"; 9 | import { useYjsStore } from "@/store/yjs"; 10 | 11 | import PointerCursor from "./PointerCursor"; 12 | 13 | export default function PointerLayer() { 14 | const layerRef = useRef(null); 15 | 16 | const { yProvider } = useYjsStore(); 17 | const awareness = (yProvider as WebsocketProvider | undefined)?.awareness; 18 | const { states: userStates, setLocalStateField } = 19 | useYjsSpaceAwarenessStates(awareness); 20 | 21 | const setLocalPointerPosition = useCallback( 22 | (position?: { x: number; y: number }) => { 23 | setLocalStateField?.("pointer", position || undefined); 24 | }, 25 | [setLocalStateField], 26 | ); 27 | 28 | useEffect(() => { 29 | const layer = layerRef.current; 30 | const stage = layer?.getStage(); 31 | 32 | if (!stage) { 33 | return undefined; 34 | } 35 | 36 | const handlePointerMove = throttle(() => { 37 | const pointerPosition = stage.getRelativePointerPosition(); 38 | setLocalPointerPosition(pointerPosition || undefined); 39 | }, 100); 40 | 41 | const handlePointerLeave = () => { 42 | setLocalPointerPosition(undefined); 43 | }; 44 | 45 | stage.on("pointermove dragmove", handlePointerMove); 46 | window.addEventListener("pointerleave", handlePointerLeave); 47 | window.addEventListener("pointerout", handlePointerLeave); 48 | return () => { 49 | stage.off("pointermove dragmove", handlePointerMove); 50 | window.removeEventListener("pointerleave", handlePointerLeave); 51 | window.removeEventListener("pointerout", handlePointerLeave); 52 | }; 53 | }, [setLocalPointerPosition]); 54 | 55 | return ( 56 | 57 | {userStates && 58 | [...userStates].map(([clientId, { color, pointer }]) => { 59 | if (clientId === awareness?.clientID) { 60 | return null; 61 | } 62 | 63 | const pointerColor = color; 64 | 65 | return ( 66 | pointer && ( 67 | 73 | ) 74 | ); 75 | })} 76 | 77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /packages/frontend/src/components/PointerCursor.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useCallback, useLayoutEffect, useRef } from "react"; 2 | import { Group, Label, Path, Tag, Text } from "react-konva"; 3 | 4 | import Konva from "konva"; 5 | 6 | type PointerCursorProps = { 7 | position?: { 8 | x: number; 9 | y: number; 10 | }; 11 | color: string; 12 | label?: string; 13 | }; 14 | 15 | const PointerCursor = memo(({ color, position, label }: PointerCursorProps) => { 16 | const ref = useRef(null); 17 | 18 | const tween = useCallback((position: { x: number; y: number }) => { 19 | const { current } = ref; 20 | 21 | if (!current) { 22 | return; 23 | } 24 | 25 | if (current.visible()) { 26 | current.to({ 27 | x: position.x, 28 | y: position.y, 29 | duration: 0.1, 30 | }); 31 | 32 | return; 33 | } 34 | 35 | current.visible(true); 36 | current.position(position); 37 | }, []); 38 | 39 | useLayoutEffect(() => { 40 | if (position?.x !== undefined && position?.y !== undefined) { 41 | tween({ x: position.x, y: position.y }); 42 | } 43 | }, [position?.x, position?.y, tween]); 44 | 45 | if (!position) { 46 | return null; 47 | } 48 | 49 | return ( 50 | // https://github.com/steveruizok/perfect-cursors/blob/main/example/src/components/Cursor.tsx 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 66 | 70 | 71 | 72 | {label && ( 73 | 83 | )} 84 | 85 | ); 86 | }); 87 | 88 | export default PointerCursor; 89 | -------------------------------------------------------------------------------- /packages/frontend/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | listen [::]:80; 4 | server_name honeyflow.life www.honeyflow.life; 5 | 6 | root /usr/share/nginx/html; 7 | index index.html; 8 | 9 | 10 | # 보안 헤더 설정 11 | add_header X-Frame-Options "SAMEORIGIN"; 12 | add_header X-XSS-Protection "1; mode=block"; 13 | add_header X-Content-Type-Options "nosniff"; 14 | add_header Referrer-Policy "strict-origin-when-cross-origin"; 15 | add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws://www.honeyflow.life;"; 16 | 17 | 18 | # gzip 설정 19 | gzip on; 20 | gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; 21 | gzip_comp_level 6; 22 | gzip_min_length 1000; 23 | 24 | # SPA 라우팅을 위한 설정 25 | location / { 26 | try_files $uri $uri/ /index.html; 27 | expires -1; 28 | } 29 | # Backend API 설정 30 | location /api/ { 31 | proxy_pass http://backend:3000; 32 | proxy_set_header Host $host; 33 | proxy_set_header X-Real-IP $remote_addr; 34 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 35 | proxy_set_header X-Forwarded-Proto $scheme; 36 | add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, OPTIONS'; 37 | add_header Access-Control-Allow-Headers 'Origin, Content-Type, Accept, Authorization'; 38 | } 39 | # Backend Socket 설정 40 | 41 | location /ws/ { 42 | proxy_pass http://backend:9001; 43 | proxy_set_header Upgrade $http_upgrade; 44 | proxy_set_header Connection "upgrade"; 45 | proxy_set_header Host $host; 46 | proxy_set_header X-Real-IP $remote_addr; 47 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 48 | proxy_set_header X-Forwarded-Proto $scheme; 49 | add_header Access-Control-Allow-Origin *; 50 | add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, OPTIONS'; 51 | add_header Access-Control-Allow-Headers 'Origin, Content-Type, Accept, Authorization'; 52 | } 53 | 54 | # 정적 파일 캐싱 55 | location /assets/ { 56 | expires 1y; 57 | add_header Cache-Control "public, no-transform"; 58 | } 59 | 60 | # 헬스체크 엔드포인트 61 | location /health { 62 | access_log off; 63 | return 200 'healthy\n'; 64 | } 65 | 66 | # favicon.ico 처리 67 | location = /favicon.ico { 68 | access_log off; 69 | expires 1d; 70 | } 71 | 72 | # 404 에러 처리 73 | error_page 404 /index.html; 74 | } -------------------------------------------------------------------------------- /docker-compose.override.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | mysql: 5 | image: mysql:8.0 6 | container_name: mysql-container 7 | ports: 8 | - "3306:3306" 9 | networks: 10 | - app-network 11 | environment: 12 | MYSQL_ROOT_PASSWORD: 1234 13 | MYSQL_DATABASE: dev_db 14 | MYSQL_USER: honey 15 | MYSQL_PASSWORD: 1234 16 | volumes: 17 | - mysql_data:/var/lib/mysql 18 | healthcheck: 19 | test: 20 | ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p1234"] 21 | interval: 10s 22 | timeout: 5s 23 | retries: 3 24 | command: --bind-address=0.0.0.0 25 | backend: 26 | build: 27 | context: . 28 | dockerfile: ./packages/backend/Dockerfile 29 | container_name: backend 30 | ports: 31 | - "3000:3000" 32 | - "9001:9001" 33 | depends_on: 34 | mysql: 35 | condition: service_healthy 36 | mongodb: 37 | condition: service_healthy 38 | environment: 39 | - MYSQL_HOST=mysql-container 40 | - MYSQL_PORT=3306 41 | - MYSQL_DATABASE=dev_db 42 | - MYSQL_PASSWORD=1234 43 | - MYSQL_USER=honey 44 | - NODE_ENV=dev 45 | - MONGO_HOST=mongodb-container 46 | - MONGO_USER=honey 47 | - MONGO_PASSWORD=1234 48 | - MONGO_DB=dev_db 49 | - LOG_LEVEL=info 50 | networks: 51 | - app-network 52 | 53 | frontend: 54 | build: 55 | context: . 56 | dockerfile: ./packages/frontend/Dockerfile 57 | container_name: frontend 58 | ports: 59 | - "80:80" 60 | depends_on: 61 | backend: 62 | condition: service_started 63 | networks: 64 | - app-network 65 | mongodb: 66 | image: mongo:latest 67 | container_name: mongodb-container 68 | ports: 69 | - "27017:27017" 70 | networks: 71 | - app-network 72 | environment: 73 | MONGO_INITDB_ROOT_USERNAME: honey 74 | MONGO_INITDB_ROOT_PASSWORD: 1234 75 | MONGO_INITDB_DATABASE: dev_db 76 | MONGODB_AUTH_MECHANISM: SCRAM-SHA-256 77 | command: ["mongod", "--bind_ip", "0.0.0.0"] 78 | volumes: 79 | - mongo_data:/data/db 80 | healthcheck: 81 | test: 82 | [ 83 | "CMD", 84 | "mongosh", 85 | "--username", 86 | "honey", 87 | "--password", 88 | "1234", 89 | "--eval", 90 | "db.runCommand({ ping: 1 })", 91 | ] 92 | interval: 10s 93 | timeout: 5s 94 | retries: 10 95 | 96 | volumes: 97 | mysql_data: 98 | mongo_data: 99 | 100 | networks: 101 | app-network: 102 | driver: bridge 103 | -------------------------------------------------------------------------------- /packages/frontend/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | @layer base { 5 | @font-face { 6 | font-family: "Pretendard Variable"; 7 | font-weight: 100 300 900; 8 | font-style: normal; 9 | font-display: swap; 10 | src: url("/PretendardVariable.woff2") format("woff2-variations"); 11 | } 12 | 13 | :root { 14 | --background: 0 0% 100%; 15 | --foreground: 20 14.3% 4.1%; 16 | --card: 0 0% 100%; 17 | --card-foreground: 20 14.3% 4.1%; 18 | --popover: 0 0% 100%; 19 | --popover-foreground: 20 14.3% 4.1%; 20 | --primary: 47.9 95.8% 53.1%; 21 | --primary-foreground: 26 83.3% 14.1%; 22 | --secondary: 60 4.8% 95.9%; 23 | --secondary-foreground: 24 9.8% 10%; 24 | --muted: 60 4.8% 95.9%; 25 | --muted-foreground: 25 5.3% 44.7%; 26 | --accent: 60 4.8% 95.9%; 27 | --accent-foreground: 24 9.8% 10%; 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 60 9.1% 97.8%; 30 | --border: 20 5.9% 90%; 31 | --input: 20 5.9% 90%; 32 | --ring: 20 14.3% 4.1%; 33 | --radius: 1rem; 34 | --chart-1: 12 76% 61%; 35 | --chart-2: 173 58% 39%; 36 | --chart-3: 197 37% 24%; 37 | --chart-4: 43 74% 66%; 38 | --chart-5: 27 87% 67%; 39 | } 40 | 41 | .dark { 42 | --background: 20 14.3% 4.1%; 43 | --foreground: 60 9.1% 97.8%; 44 | --card: 20 14.3% 4.1%; 45 | --card-foreground: 60 9.1% 97.8%; 46 | --popover: 20 14.3% 4.1%; 47 | --popover-foreground: 60 9.1% 97.8%; 48 | --primary: 47.9 95.8% 53.1%; 49 | --primary-foreground: 26 83.3% 14.1%; 50 | --secondary: 12 6.5% 15.1%; 51 | --secondary-foreground: 60 9.1% 97.8%; 52 | --muted: 12 6.5% 15.1%; 53 | --muted-foreground: 24 5.4% 63.9%; 54 | --accent: 12 6.5% 15.1%; 55 | --accent-foreground: 60 9.1% 97.8%; 56 | --destructive: 0 62.8% 30.6%; 57 | --destructive-foreground: 60 9.1% 97.8%; 58 | --border: 12 6.5% 15.1%; 59 | --input: 12 6.5% 15.1%; 60 | --ring: 35.5 91.7% 32.9%; 61 | --chart-1: 220 70% 50%; 62 | --chart-2: 160 60% 45%; 63 | --chart-3: 30 80% 55%; 64 | --chart-4: 280 65% 60%; 65 | --chart-5: 340 75% 55%; 66 | } 67 | 68 | body { 69 | font-family: 70 | "Pretendard Variable", 71 | Pretendard, 72 | -apple-system, 73 | BlinkMacSystemFont, 74 | system-ui, 75 | Roboto, 76 | "Helvetica Neue", 77 | "Segoe UI", 78 | "Apple SD Gothic Neo", 79 | "Noto Sans KR", 80 | "Malgun Gothic", 81 | "Apple Color Emoji", 82 | "Segoe UI Emoji", 83 | "Segoe UI Symbol", 84 | sans-serif; 85 | } 86 | } 87 | @layer base { 88 | * { 89 | @apply border-border; 90 | } 91 | body { 92 | @apply bg-background text-foreground; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /packages/backend/src/space/space.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { SpaceService } from './space.service'; 3 | import { getModelToken } from '@nestjs/mongoose'; 4 | import { SpaceDocument } from './space.schema'; 5 | import { SpaceValidation } from './space.validation.service'; 6 | import { Model } from 'mongoose'; 7 | 8 | jest.mock('uuid', () => ({ 9 | v4: jest.fn(() => 'mock-uuid'), 10 | })); 11 | 12 | describe('SpaceService', () => { 13 | let spaceService: SpaceService; 14 | let spaceModel: Model; 15 | let spaceValidation: SpaceValidation; 16 | 17 | beforeEach(async () => { 18 | const mockSpaceModel = { 19 | findOne: jest.fn().mockReturnValue({ 20 | exec: jest.fn(), 21 | }), 22 | findOneAndUpdate: jest.fn().mockReturnValue({ 23 | exec: jest.fn(), 24 | }), 25 | countDocuments: jest.fn(), 26 | create: jest.fn(), 27 | }; 28 | 29 | const mockSpaceValidation = { 30 | validateSpaceLimit: jest.fn().mockResolvedValue(undefined), 31 | validateParentNodeExists: jest.fn().mockResolvedValue(undefined), 32 | }; 33 | 34 | const module: TestingModule = await Test.createTestingModule({ 35 | providers: [ 36 | SpaceService, 37 | { 38 | provide: getModelToken(SpaceDocument.name), 39 | useValue: mockSpaceModel, 40 | }, 41 | { 42 | provide: SpaceValidation, 43 | useValue: mockSpaceValidation, 44 | }, 45 | ], 46 | }).compile(); 47 | 48 | spaceService = module.get(SpaceService); 49 | spaceModel = module.get>( 50 | getModelToken(SpaceDocument.name), 51 | ); 52 | spaceValidation = module.get(SpaceValidation); 53 | }); 54 | 55 | describe('getBreadcrumb', () => { 56 | it('스페이스의 경로를 반환해야 한다', async () => { 57 | const mockSpaces = [ 58 | { id: 'parent-id', name: 'Parent Space', parentSpaceId: null }, 59 | { id: '123', name: 'Child Space', parentSpaceId: 'parent-id' }, 60 | ]; 61 | 62 | (spaceModel.findOne as jest.Mock) 63 | .mockReturnValueOnce({ 64 | exec: jest.fn().mockResolvedValue(mockSpaces[1]), 65 | }) 66 | .mockReturnValueOnce({ 67 | exec: jest.fn().mockResolvedValue(mockSpaces[0]), 68 | }); 69 | 70 | const result = await spaceService.getBreadcrumb('123'); 71 | 72 | expect(spaceModel.findOne).toHaveBeenCalledWith({ id: '123' }); 73 | expect(spaceModel.findOne).toHaveBeenCalledWith({ id: 'parent-id' }); 74 | expect(result).toEqual([ 75 | { name: 'Parent Space', url: 'parent-id' }, 76 | { name: 'Child Space', url: '123' }, 77 | ]); 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /packages/frontend/src/hooks/useMilkdownEditor.ts: -------------------------------------------------------------------------------- 1 | import { defaultKeymap } from "@codemirror/commands"; 2 | import { languages } from "@codemirror/language-data"; 3 | import { oneDark } from "@codemirror/theme-one-dark"; 4 | import { keymap } from "@codemirror/view"; 5 | import { html } from "@milkdown/kit/component"; 6 | import { 7 | codeBlockComponent, 8 | codeBlockConfig, 9 | } from "@milkdown/kit/component/code-block"; 10 | import { Editor, rootCtx } from "@milkdown/kit/core"; 11 | import { Ctx } from "@milkdown/kit/ctx"; 12 | import { block } from "@milkdown/kit/plugin/block"; 13 | import { cursor } from "@milkdown/kit/plugin/cursor"; 14 | import { commonmark } from "@milkdown/kit/preset/commonmark"; 15 | import { gfm } from "@milkdown/kit/preset/gfm"; 16 | import { collab } from "@milkdown/plugin-collab"; 17 | import { useEditor } from "@milkdown/react"; 18 | import { nord } from "@milkdown/theme-nord"; 19 | import { 20 | ReactPluginViewComponent, 21 | usePluginViewFactory, 22 | } from "@prosemirror-adapter/react"; 23 | import { basicSetup } from "codemirror"; 24 | 25 | import { placeholder, placeholderCtx } from "@/lib/milkdown-plugin-placeholder"; 26 | 27 | const check = html` 28 | 36 | 41 | 42 | `; 43 | 44 | type useMilkdownEditorProps = { 45 | placeholderValue?: string; 46 | BlockView: ReactPluginViewComponent; 47 | }; 48 | 49 | export default function useMilkdownEditor({ 50 | placeholderValue = "제목을 입력하세요", 51 | BlockView, 52 | }: useMilkdownEditorProps) { 53 | const pluginViewFactory = usePluginViewFactory(); 54 | 55 | return useEditor((root) => { 56 | return Editor.make() 57 | .config((ctx: Ctx) => { 58 | ctx.set(rootCtx, root); 59 | ctx.set(placeholderCtx, placeholderValue); 60 | ctx.set(block.key, { 61 | view: pluginViewFactory({ 62 | component: BlockView, 63 | }), 64 | }); 65 | ctx.update(codeBlockConfig.key, (defaultConfig) => ({ 66 | ...defaultConfig, 67 | languages, 68 | extensions: [basicSetup, oneDark, keymap.of(defaultKeymap)], 69 | renderLanguage: (language, selected) => { 70 | return html`${selected ? check : null}${language}`; 72 | }, 73 | })); 74 | }) 75 | .config(nord) 76 | .use(commonmark) 77 | .use(gfm) 78 | .use(placeholder) 79 | .use(codeBlockComponent) 80 | .use(block) 81 | .use(cursor) 82 | .use(collab); 83 | }, []); 84 | } 85 | -------------------------------------------------------------------------------- /packages/backend/src/yjs/yjs.gateway.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { YjsGateway } from './yjs.gateway'; 3 | import { CollaborativeService } from '../collaborative/collaborative.service'; 4 | import { WebSocket } from 'ws'; 5 | import { Request } from 'express'; 6 | import { ERROR_MESSAGES } from '../common/constants/error.message.constants'; 7 | import { WebsocketStatus } from '../common/constants/websocket.constants'; 8 | 9 | describe('YjsGateway', () => { 10 | let gateway: YjsGateway; 11 | let collaborativeService: CollaborativeService; 12 | 13 | beforeEach(async () => { 14 | const mockCollaborativeService = { 15 | findByNote: jest.fn(), 16 | findBySpace: jest.fn(), 17 | updateByNote: jest.fn(), 18 | updateBySpace: jest.fn(), 19 | }; 20 | 21 | const module: TestingModule = await Test.createTestingModule({ 22 | providers: [ 23 | YjsGateway, 24 | { 25 | provide: CollaborativeService, 26 | useValue: mockCollaborativeService, 27 | }, 28 | ], 29 | }).compile(); 30 | 31 | gateway = module.get(YjsGateway); 32 | collaborativeService = 33 | module.get(CollaborativeService); 34 | }); 35 | 36 | describe('handleConnection', () => { 37 | it('유효하지 않은 URL로 WebSocket 연결을 닫아야 한다', async () => { 38 | const connection = { 39 | close: jest.fn(), 40 | } as unknown as WebSocket; 41 | 42 | const request = { 43 | url: '/invalid-url', 44 | } as Request; 45 | 46 | await gateway.handleConnection(connection, request); 47 | 48 | expect(connection.close).toHaveBeenCalledWith( 49 | WebsocketStatus.POLICY_VIOLATION, 50 | ERROR_MESSAGES.SOCKET.INVALID_URL, 51 | ); 52 | }); 53 | 54 | it('유효한 노트 URL로 WebSocket 연결을 초기화해야 한다', async () => { 55 | const connection = { 56 | close: jest.fn(), 57 | } as unknown as WebSocket; 58 | 59 | const request = { 60 | url: '/note/123', 61 | } as Request; 62 | 63 | (collaborativeService.findByNote as jest.Mock).mockResolvedValue({ 64 | id: '123', 65 | }); 66 | 67 | await gateway.handleConnection(connection, request); 68 | 69 | expect(collaborativeService.findByNote).toHaveBeenCalledWith('123'); 70 | }); 71 | 72 | it('유효한 스페이스 URL로 WebSocket 연결을 초기화해야 한다', async () => { 73 | const connection = { 74 | close: jest.fn(), 75 | } as unknown as WebSocket; 76 | 77 | const request = { 78 | url: '/space/123', 79 | } as Request; 80 | 81 | (collaborativeService.findBySpace as jest.Mock).mockResolvedValue({ 82 | id: '123', 83 | }); 84 | 85 | await gateway.handleConnection(connection, request); 86 | 87 | expect(collaborativeService.findBySpace).toHaveBeenCalledWith('123'); 88 | }); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /packages/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview", 11 | "storybook": "storybook dev -p 6006", 12 | "build-storybook": "storybook build", 13 | "playwright:test": "playwright test" 14 | }, 15 | "dependencies": { 16 | "@codemirror/commands": "^6.7.1", 17 | "@codemirror/language-data": "^6.5.1", 18 | "@codemirror/theme-one-dark": "^6.1.2", 19 | "@codemirror/view": "^6.35.0", 20 | "@milkdown/kit": "^7.5.5", 21 | "@milkdown/plugin-collab": "^7.5.0", 22 | "@milkdown/react": "^7.5.0", 23 | "@milkdown/theme-nord": "^7.5.0", 24 | "@prosemirror-adapter/react": "^0.2.6", 25 | "@radix-ui/react-context-menu": "^2.2.2", 26 | "@radix-ui/react-dialog": "^1.1.2", 27 | "@radix-ui/react-dropdown-menu": "^2.1.2", 28 | "@radix-ui/react-hover-card": "^1.1.2", 29 | "@radix-ui/react-label": "^2.1.0", 30 | "@radix-ui/react-popover": "^1.1.2", 31 | "@radix-ui/react-slot": "^1.1.0", 32 | "class-variance-authority": "^0.7.0", 33 | "clsx": "^2.1.1", 34 | "codemirror": "^6.0.1", 35 | "konva": "^9.3.16", 36 | "lucide-react": "^0.454.0", 37 | "react": "^18.3.1", 38 | "react-dom": "^18.3.1", 39 | "react-konva": "^18.2.10", 40 | "react-konva-utils": "^1.0.6", 41 | "react-router-dom": "^6.28.0", 42 | "shared": "workspace:*", 43 | "tailwind-merge": "^2.5.4", 44 | "tailwindcss-animate": "^1.0.7", 45 | "y-websocket": "^2.0.4", 46 | "yjs": "^13.6.20" 47 | }, 48 | "devDependencies": { 49 | "@chromatic-com/storybook": "^3.2.2", 50 | "@eslint/js": "^9.13.0", 51 | "@playwright/test": "^1.48.2", 52 | "@storybook/addon-essentials": "^8.4.2", 53 | "@storybook/addon-interactions": "^8.4.2", 54 | "@storybook/addon-onboarding": "^8.4.2", 55 | "@storybook/addons": "^7.6.17", 56 | "@storybook/blocks": "^8.4.2", 57 | "@storybook/react": "^8.4.2", 58 | "@storybook/react-vite": "^8.4.2", 59 | "@storybook/test": "^8.4.2", 60 | "@tailwindcss/typography": "^0.5.15", 61 | "@trivago/prettier-plugin-sort-imports": "^4.3.0", 62 | "@types/node": "^22.9.0", 63 | "@types/react": "^18.3.12", 64 | "@types/react-dom": "^18.3.1", 65 | "@vitejs/plugin-react": "^4.3.3", 66 | "autoprefixer": "^10.4.20", 67 | "eslint": "^9.13.0", 68 | "eslint-import-resolver-typescript": "^3.6.3", 69 | "eslint-plugin-react-hooks": "^5.0.0", 70 | "eslint-plugin-react-refresh": "^0.4.14", 71 | "eslint-plugin-storybook": "^0.11.0", 72 | "globals": "^15.11.0", 73 | "postcss": "^8.4.47", 74 | "prettier-plugin-tailwindcss": "^0.6.8", 75 | "storybook": "^8.4.2", 76 | "tailwindcss": "^3.4.14", 77 | "typescript": "~5.6.2", 78 | "typescript-eslint": "^8.11.0", 79 | "vite": "^5.4.10", 80 | "vitest": "^2.1.4" 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /packages/backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "build": "nest build", 10 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 11 | "start": "nest start", 12 | "start:dev": "nest start --watch", 13 | "start:debug": "nest start --debug --watch", 14 | "start:prod": "node dist/backend/src/main.js", 15 | "test": "jest", 16 | "test:watch": "jest --watch", 17 | "test:cov": "jest --coverage", 18 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 19 | "test:e2e": "jest --config ./test/jest-e2e.json" 20 | }, 21 | "dependencies": { 22 | "@elastic/elasticsearch": "^8.16.1", 23 | "@nestjs/cache-manager": "^2.3.0", 24 | "@nestjs/common": "^10.4.7", 25 | "@nestjs/config": "^3.3.0", 26 | "@nestjs/core": "^10.4.7", 27 | "@nestjs/elasticsearch": "^10.0.2", 28 | "@nestjs/mongoose": "^10.1.0", 29 | "@nestjs/platform-express": "^10.0.0", 30 | "@nestjs/platform-socket.io": "^10.4.8", 31 | "@nestjs/platform-ws": "^10.4.8", 32 | "@nestjs/schedule": "^4.1.1", 33 | "@nestjs/swagger": "^8.0.7", 34 | "@nestjs/terminus": "^10.2.3", 35 | "@nestjs/typeorm": "^10.0.2", 36 | "@nestjs/websockets": "^10.4.8", 37 | "dotenv": "^16.4.5", 38 | "mongoose": "^8.8.1", 39 | "mysql2": "^3.11.4", 40 | "nest-winston": "^1.9.7", 41 | "prosemirror": "^0.11.1", 42 | "reflect-metadata": "^0.2.2", 43 | "rxjs": "^7.8.1", 44 | "shared": "workspace:*", 45 | "socket.io": "^4.8.1", 46 | "swagger-ui-express": "^5.0.1", 47 | "typeorm": "^0.3.20", 48 | "utils": "link:@milkdown/kit/utils", 49 | "uuid": "^11.0.3", 50 | "winston": "^3.17.0", 51 | "winston-daily-rotate-file": "^5.0.0", 52 | "ws": "^8.18.0", 53 | "y-socket.io": "^1.1.3", 54 | "y-websocket": "^2.0.4", 55 | "yjs": "^13.6.20" 56 | }, 57 | "devDependencies": { 58 | "@nestjs/cli": "^10.0.0", 59 | "@nestjs/schematics": "^10.0.0", 60 | "@nestjs/testing": "^10.0.0", 61 | "@types/express": "^5.0.0", 62 | "@types/jest": "^29.5.2", 63 | "@types/mongoose": "^5.11.97", 64 | "@types/node": "^20.17.6", 65 | "@types/socket.io": "^3.0.2", 66 | "@types/supertest": "^6.0.0", 67 | "@types/uuid": "^10.0.0", 68 | "@types/ws": "^8.5.13", 69 | "jest": "^29.5.0", 70 | "prettier": "^3.0.0", 71 | "source-map-support": "^0.5.21", 72 | "supertest": "^7.0.0", 73 | "ts-jest": "^29.1.0", 74 | "ts-loader": "^9.4.3", 75 | "ts-node": "^10.9.2", 76 | "tsconfig-paths": "^4.2.0", 77 | "typescript": "^5.6.3" 78 | }, 79 | "jest": { 80 | "moduleFileExtensions": [ 81 | "js", 82 | "json", 83 | "ts" 84 | ], 85 | "rootDir": "src", 86 | "testRegex": ".*\\.spec\\.ts$", 87 | "transform": { 88 | "^.+\\.(t|j)s$": "ts-jest" 89 | }, 90 | "collectCoverageFrom": [ 91 | "**/*.(t|j)s" 92 | ], 93 | "coverageDirectory": "../coverage", 94 | "testEnvironment": "node" 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /.github/workflows/FRONTEND_PR_CHECK.yml: -------------------------------------------------------------------------------- 1 | name: Frontend PR Check 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | paths: 7 | - 'packages/frontend/**' 8 | - 'packages/shared/**' 9 | 10 | jobs: 11 | build-check: 12 | if: contains(github.head_ref, 'dev-fe') 13 | runs-on: ubuntu-latest 14 | 15 | # Checks API 권한 16 | permissions: 17 | checks: write 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | 22 | - uses: pnpm/action-setup@v2 23 | with: 24 | version: 9.4.0 25 | 26 | - uses: actions/setup-node@v3 27 | with: 28 | node-version: '22.9.0' 29 | cache: 'pnpm' 30 | 31 | - name: Install dependencies 32 | run: pnpm install 33 | 34 | - name: Build frontend 35 | id: build 36 | working-directory: packages/frontend 37 | run: pnpm build 38 | continue-on-error: true 39 | 40 | - name: Process Build Result 41 | uses: actions/github-script@v6 42 | with: 43 | script: | 44 | const buildOutcome = '${{ steps.build.outcome }}'; 45 | 46 | await github.rest.checks.create({ 47 | owner: context.repo.owner, 48 | repo: context.repo.name, 49 | name: 'Frontend Build', 50 | head_sha: context.sha, 51 | status: 'completed', 52 | conclusion: buildOutcome === 'success' ? 'success' : 'failure', 53 | output: { 54 | title: buildOutcome === 'success' 55 | ? '🎉 Frontend Build Successful' 56 | : '❌ Frontend Build Failed', 57 | 58 | summary: buildOutcome === 'success' 59 | ? [ 60 | '## ✅ Build Status: Success', 61 | '', 62 | '### Build Information:', 63 | '- **Build Time**: ' + new Date().toISOString(), 64 | '- **Branch**: ' + context.ref, 65 | '', 66 | '✨ Ready to be reviewed!' 67 | ].join('\n') 68 | : [ 69 | '## ❌ Build Status: Failed', 70 | '', 71 | '### Error Information:', 72 | '- **Build Time**: ' + new Date().toISOString(), 73 | '- **Branch**: ' + context.ref, 74 | '', 75 | '### Next Steps:', 76 | '1. Check the build logs for detailed error messages', 77 | '2. Fix the identified issues', 78 | '3. Push your changes to trigger a new build', 79 | '', 80 | '> Need help? Contact the frontend team.' 81 | ].join('\n'), 82 | 83 | text: buildOutcome === 'success' 84 | ? '자세한 빌드 로그는 Actions 탭에서 확인하실 수 있습니다.' 85 | : '빌드 실패 원인을 확인하시려면 위의 "Details"를 클릭하세요.' 86 | } 87 | }); 88 | 89 | if (buildOutcome === 'failure') { 90 | core.setFailed('Frontend build failed'); 91 | } -------------------------------------------------------------------------------- /packages/backend/src/note/note.service.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable, Logger } from '@nestjs/common'; 2 | import { InjectModel } from '@nestjs/mongoose'; 3 | import { Model } from 'mongoose'; 4 | import { v4 as uuid } from 'uuid'; 5 | 6 | import { ERROR_MESSAGES } from '../common/constants/error.message.constants'; 7 | import { NoteDocument } from './note.schema'; 8 | 9 | @Injectable() 10 | export class NoteService { 11 | private readonly logger = new Logger(NoteService.name); 12 | 13 | constructor( 14 | @InjectModel(NoteDocument.name) 15 | private readonly noteModel: Model, 16 | ) {} 17 | 18 | async create(userId: string, noteName: string) { 19 | this.logger.log(`사용자 ${userId}에 대한 새로운 노트를 생성 중입니다.`); 20 | 21 | const noteDto = { 22 | id: uuid(), 23 | userId, 24 | name: noteName, 25 | }; 26 | 27 | const note = await this.noteModel.create(noteDto); 28 | 29 | this.logger.debug(`노트 생성 완료 - ID: ${note.id}, 이름: ${note.name}`); 30 | 31 | return note; 32 | } 33 | 34 | async findById(id: string) { 35 | this.logger.log(`ID가 ${id}인 노트를 검색 중입니다.`); 36 | 37 | const note = await this.noteModel.findOne({ id }).exec(); 38 | 39 | this.logger.debug(`ID가 ${id}인 노트 검색 결과: ${!!note}`); 40 | 41 | return note; 42 | } 43 | 44 | async existsById(id: string) { 45 | this.logger.log(`ID가 ${id}인 노트의 존재 여부를 확인 중입니다.`); 46 | 47 | const note = await this.noteModel.findOne({ id }).exec(); 48 | const exists = !!note; 49 | 50 | this.logger.debug(`ID가 ${id}인 노트 존재 여부: ${exists}`); 51 | 52 | return exists; 53 | } 54 | 55 | async updateContent(id: string, newContent: string) { 56 | this.logger.log(`ID가 ${id}인 노트의 내용을 업데이트 중입니다.`); 57 | 58 | const note = await this.findById(id); 59 | 60 | if (!note) { 61 | this.logger.error(`업데이트 실패: ID가 ${id}인 노트를 찾을 수 없습니다.`); 62 | throw new BadRequestException(ERROR_MESSAGES.NOTE.NOT_FOUND); 63 | } 64 | 65 | this.logger.debug(`이전 내용: ${note.content}`); 66 | note.content = newContent; 67 | 68 | try { 69 | const updatedNote = await note.save(); 70 | 71 | this.logger.log(`ID가 ${id}인 노트 내용 업데이트 완료.`); 72 | this.logger.debug(`업데이트된 내용: ${updatedNote.content}`); 73 | 74 | return updatedNote; 75 | } catch (error) { 76 | this.logger.error( 77 | `ID가 ${id}인 노트의 내용 업데이트 중 오류 발생.`, 78 | error.stack, 79 | ); 80 | throw new BadRequestException(ERROR_MESSAGES.NOTE.UPDATE_FAILED); 81 | } 82 | } 83 | async deleteById(id: string) { 84 | this.logger.log(`ID가 ${id}인 노트를 삭제하는 중입니다.`); 85 | 86 | try { 87 | const result = await this.noteModel.deleteOne({ id }).exec(); 88 | 89 | if (result.deletedCount === 0) { 90 | this.logger.warn(`삭제 실패: ID가 ${id}인 노트를 찾을 수 없습니다.`); 91 | throw new BadRequestException(ERROR_MESSAGES.NOTE.NOT_FOUND); 92 | } 93 | 94 | this.logger.log(`ID가 ${id}인 노트 삭제 완료.`); 95 | return { success: true, message: '노트가 성공적으로 삭제되었습니다.' }; 96 | } catch (error) { 97 | this.logger.error(`ID가 ${id}인 노트 삭제 중 오류 발생.`, error.stack); 98 | throw new BadRequestException(ERROR_MESSAGES.NOTE.DELETE_FAILED); 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /packages/frontend/src/components/ui/breadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { ChevronRight, MoreHorizontal } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Breadcrumb = React.forwardRef< 8 | HTMLElement, 9 | React.ComponentPropsWithoutRef<"nav"> & { 10 | separator?: React.ReactNode 11 | } 12 | >(({ ...props }, ref) =>