├── BE ├── apps │ ├── api-server │ │ ├── src │ │ │ ├── exceptionFilter │ │ │ │ └── http.exception.filter.ts │ │ │ ├── exceptions │ │ │ │ ├── index.ts │ │ │ │ └── exception.ts │ │ │ ├── decorators │ │ │ │ ├── index.ts │ │ │ │ └── user.decorator.ts │ │ │ ├── pipes │ │ │ │ ├── index.ts │ │ │ │ └── audio.file.validation.pipe.ts │ │ │ ├── strategies │ │ │ │ ├── index.ts │ │ │ │ ├── kakao.strategy.ts │ │ │ │ └── github.strategy.ts │ │ │ ├── modules │ │ │ │ ├── user │ │ │ │ │ ├── dto │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── user.create.dto.ts │ │ │ │ │ │ └── user.info.dto.ts │ │ │ │ │ ├── user.module.ts │ │ │ │ │ ├── user.service.spec.ts │ │ │ │ │ ├── user.controller.ts │ │ │ │ │ ├── user.controller.spec.ts │ │ │ │ │ └── user.service.ts │ │ │ │ ├── mindmap │ │ │ │ │ ├── dto │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── create.mindmap.dto.ts │ │ │ │ │ │ └── update.mindmap.dto.ts │ │ │ │ │ ├── mindmap.service.spec.ts │ │ │ │ │ ├── mindmap.controller.spec.ts │ │ │ │ │ ├── mindmap.module.ts │ │ │ │ │ └── mindmap.controller.ts │ │ │ │ ├── node │ │ │ │ │ ├── dto │ │ │ │ │ │ ├── update.node.dto.ts │ │ │ │ │ │ └── node.dto.ts │ │ │ │ │ ├── node.module.ts │ │ │ │ │ └── node.service.spec.ts │ │ │ │ ├── ai │ │ │ │ │ ├── dto │ │ │ │ │ │ ├── ai.dto.ts │ │ │ │ │ │ ├── audio.upload.dto.ts │ │ │ │ │ │ ├── clova.speech.request.dtd.ts │ │ │ │ │ │ └── openai.request.dto.ts │ │ │ │ │ ├── ai.service.spec.ts │ │ │ │ │ ├── ai.module.ts │ │ │ │ │ ├── ai.controller.spec.ts │ │ │ │ │ └── ai.controller.ts │ │ │ │ ├── subscriber │ │ │ │ │ ├── subscriber.module.ts │ │ │ │ │ └── subscriber.service.spec.ts │ │ │ │ ├── dashboard │ │ │ │ │ ├── dashboard.module.ts │ │ │ │ │ ├── dashboard.service.spec.ts │ │ │ │ │ ├── dashboard.controller.spec.ts │ │ │ │ │ ├── dashboard.controller.ts │ │ │ │ │ └── dashboard.service.ts │ │ │ │ ├── connection │ │ │ │ │ ├── connection.module.ts │ │ │ │ │ ├── dto │ │ │ │ │ │ └── connection.query.dto.ts │ │ │ │ │ ├── connection.controller.spec.ts │ │ │ │ │ ├── connection.service.spec.ts │ │ │ │ │ └── connection.controller.ts │ │ │ │ └── auth │ │ │ │ │ ├── auth.service.spec.ts │ │ │ │ │ ├── auth.module.ts │ │ │ │ │ └── auth.controller.spec.ts │ │ │ ├── common │ │ │ │ └── constant.ts │ │ │ ├── middlewares │ │ │ │ └── logger.middleware.ts │ │ │ ├── main.ts │ │ │ └── app.module.ts │ │ ├── test │ │ │ ├── jest-e2e.json │ │ │ └── app.e2e-spec.ts │ │ ├── tsconfig.app.json │ │ └── package.json │ └── socket-server │ │ ├── src │ │ ├── exceptions │ │ │ ├── index.ts │ │ │ └── exception.ts │ │ ├── pipes │ │ │ ├── index.ts │ │ │ ├── mindmap.validation.pipe.ts │ │ │ └── ws.validation.pipe.ts │ │ ├── modules │ │ │ ├── map │ │ │ │ ├── dto │ │ │ │ │ ├── node │ │ │ │ │ │ ├── update.mindmap.dto.ts │ │ │ │ │ │ ├── delete.node.dto.ts │ │ │ │ │ │ ├── location.dto.ts │ │ │ │ │ │ ├── create.node.dto.ts │ │ │ │ │ │ └── update.node.dto.ts │ │ │ │ │ ├── ai.request.dto.ts │ │ │ │ │ ├── update.title.dto.ts │ │ │ │ │ ├── update.content.dto.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── map.module.ts │ │ │ │ ├── map.gateway.spec.ts │ │ │ │ └── map.service.spec.ts │ │ │ └── subscriber │ │ │ │ ├── subscriber.module.ts │ │ │ │ ├── subscriber.service.spec.ts │ │ │ │ └── subscriber.service.ts │ │ ├── main.ts │ │ ├── exceptionfilter │ │ │ └── ws.exceptionFilter.ts │ │ ├── app.module.ts │ │ └── guards │ │ │ └── ws.jwt.auth.guard.ts │ │ ├── test │ │ ├── jest-e2e.json │ │ └── app.e2e-spec.ts │ │ ├── tsconfig.app.json │ │ └── package.json ├── libs │ ├── jwt │ │ ├── src │ │ │ ├── index.ts │ │ │ ├── optional.jwt.guard.ts │ │ │ └── jwt.strategy.ts │ │ └── tsconfig.lib.json │ ├── entity │ │ ├── src │ │ │ ├── enum │ │ │ │ └── role.enum.ts │ │ │ ├── index.ts │ │ │ ├── user.entity.ts │ │ │ ├── user.mindmap.role.ts │ │ │ ├── node.entity.ts │ │ │ └── mindmap.entity.ts │ │ └── tsconfig.lib.json │ ├── publisher │ │ ├── src │ │ │ ├── index.ts │ │ │ ├── publisher.module.ts │ │ │ ├── publisher.service.spec.ts │ │ │ └── publisher.service.ts │ │ └── tsconfig.lib.json │ └── config │ │ ├── src │ │ ├── index.ts │ │ ├── jwt.config.ts │ │ ├── typeorm.config.ts │ │ ├── redis.config.ts │ │ └── logger.config.ts │ │ └── tsconfig.lib.json ├── tsconfig.build.json ├── Dockerfile.api.dev ├── Dockerfile.socket.dev ├── .prettierrc ├── Dockerfile.api ├── Dockerfile.socket ├── .eslintrc.js ├── .gitignore └── tsconfig.json ├── client ├── src │ ├── constants │ │ ├── node.ts │ │ ├── color.ts │ │ ├── modeview.ts │ │ └── uploadLimit.ts │ ├── vite-env.d.ts │ ├── App.css │ ├── konva_mindmap │ │ ├── types │ │ │ ├── location.ts │ │ │ ├── dimension.ts │ │ │ └── dashboard.ts │ │ ├── utils │ │ │ ├── findRootNodeKey.ts │ │ │ ├── throttle.ts │ │ │ ├── download.ts │ │ │ ├── findLastLeafNode.ts │ │ │ ├── checkNodeCount.ts │ │ │ ├── findParentNodeKey.ts │ │ │ ├── vector.ts │ │ │ ├── nodeAttrs.ts │ │ │ ├── collision.ts │ │ │ ├── getNewNodePosition.ts │ │ │ ├── points.ts │ │ │ └── select.ts │ │ ├── components │ │ │ ├── ConnectedLine.tsx │ │ │ ├── EditableTextInput.tsx │ │ │ ├── DrawMindMap.tsx │ │ │ └── NodeTool.tsx │ │ ├── hooks │ │ │ ├── useLayerEvent.ts │ │ │ ├── useCollisionDetection.ts │ │ │ ├── useCollisionDetectionForWorker.ts │ │ │ └── useAdjustedStage.ts │ │ ├── events │ │ │ ├── ratioSizing.ts │ │ │ └── deleteNode.ts │ │ └── test │ │ │ └── line.test.ts │ ├── assets │ │ ├── logo.png │ │ ├── clovaX.png │ │ ├── lock.webp │ │ ├── warning.png │ │ ├── notFound.webp │ │ ├── dashbordIcon.png │ │ ├── kakao_login.png │ │ └── github-mark-white.png │ ├── types │ │ ├── mindmap.ts │ │ ├── auth.ts │ │ ├── store.ts │ │ ├── Node.ts │ │ └── NodePayload.ts │ ├── utils │ │ ├── logging.ts │ │ ├── extractDate.ts │ │ └── formData.ts │ ├── hooks │ │ ├── useTokenRefresh.ts │ │ ├── useModal.ts │ │ ├── useAccordion.ts │ │ ├── useContent.ts │ │ ├── useMindMapTitle.ts │ │ ├── useWindowEventListener.ts │ │ ├── useGroupSelect.ts │ │ ├── useMinutes.ts │ │ ├── useAiCount.ts │ │ ├── useSection.ts │ │ ├── useAuth.ts │ │ ├── useToast.ts │ │ ├── useLoading.ts │ │ ├── useUpload.ts │ │ └── useHistoryState.ts │ ├── api │ │ ├── dashboard.api.ts │ │ ├── fetchHooks │ │ │ ├── useDashBoard.ts │ │ │ └── useDeleteMindMap.ts │ │ ├── mindmap.api.ts │ │ ├── ai.api.ts │ │ └── auth.api.ts │ ├── components │ │ ├── common │ │ │ ├── Spinner.tsx │ │ │ ├── aiSpinner.tsx │ │ │ ├── Toast │ │ │ │ ├── ToastContainer.tsx │ │ │ │ └── Toast.tsx │ │ │ ├── ArrowBox.tsx │ │ │ ├── Error.tsx │ │ │ ├── Modal.tsx │ │ │ ├── Forbidden.tsx │ │ │ └── NotFound.tsx │ │ ├── Dashboard │ │ │ ├── index.tsx │ │ │ ├── NoMindMap.tsx │ │ │ └── GuestDashBoard.tsx │ │ ├── MindMapMainSection │ │ │ ├── ControlSection │ │ │ │ ├── ListView │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── NodeList.tsx │ │ │ │ ├── UploadAvailabilityArrowBox.tsx │ │ │ │ └── index.tsx │ │ │ ├── MindMapView.tsx │ │ │ └── index.tsx │ │ ├── Modal │ │ │ ├── OfflineModal.tsx │ │ │ ├── ConfirmUploadModal.tsx │ │ │ ├── DeleteMindMapModal.tsx │ │ │ ├── ConfirmResetModal.tsx │ │ │ ├── LatestMindMapModal.tsx │ │ │ ├── GuestNewMindMapModal.tsx │ │ │ ├── ShareModal.tsx │ │ │ ├── ProfileModal.tsx │ │ │ └── LoginModal.tsx │ │ ├── Sidebar │ │ │ ├── OverviewButton.tsx │ │ │ ├── ToggleButton.tsx │ │ │ └── index.tsx │ │ ├── Authorize │ │ │ └── index.tsx │ │ ├── MindMapCanvas │ │ │ ├── NoNodeInform.tsx │ │ │ └── CanvasButtons.tsx │ │ ├── MindMapHeader │ │ │ ├── Profile.tsx │ │ │ └── MindMapHeaderButtons.tsx │ │ └── Minutes │ │ │ └── index.tsx │ ├── App.tsx │ ├── store │ │ ├── useSideBar.ts │ │ ├── createAuthSlice.ts │ │ ├── createRoleSlice.ts │ │ ├── createSharedSlice.ts │ │ ├── useConnectionStore.ts │ │ └── createMindMapOwnershipSlice.ts │ ├── pages │ │ ├── auth │ │ │ └── index.tsx │ │ ├── Mindmap │ │ │ └── index.tsx │ │ ├── Main │ │ │ └── index.tsx │ │ └── layout │ │ │ └── index.tsx │ └── index.css ├── public │ ├── logo.png │ └── thumbnail.png ├── postcss.config.js ├── .gitignore ├── .prettierrc ├── vite.config.ts ├── tailwind.config.js ├── .eslintrc ├── tsconfig.json ├── index.html └── README.md ├── .dockerignore ├── .gitignore ├── Dockerfile.nginx.dev ├── Dockerfile.fe.dev ├── .github ├── pull_request_template.md └── workflows │ ├── be-ci.yaml │ ├── fe-ci.yaml │ └── deploy.yaml ├── Dockerfile.nginx ├── docker-compose.yml ├── nginx ├── dev.conf └── default.conf └── Docker-compose.dev.yml /BE/apps/api-server/src/exceptionFilter/http.exception.filter.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/constants/node.ts: -------------------------------------------------------------------------------- 1 | export const NODE_DEPTH_LIMIT = 5; 2 | -------------------------------------------------------------------------------- /client/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | *Dockerfile* 3 | node_modules 4 | dist 5 | .env -------------------------------------------------------------------------------- /BE/apps/api-server/src/exceptions/index.ts: -------------------------------------------------------------------------------- 1 | export * from './exception'; 2 | -------------------------------------------------------------------------------- /BE/apps/socket-server/src/exceptions/index.ts: -------------------------------------------------------------------------------- 1 | export * from './exception'; 2 | -------------------------------------------------------------------------------- /client/src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | width: 100%; 3 | height: 100%; 4 | } 5 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/decorators/index.ts: -------------------------------------------------------------------------------- 1 | export * from './user.decorator'; 2 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/pipes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './audio.file.validation.pipe'; 2 | -------------------------------------------------------------------------------- /BE/libs/jwt/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './optional.jwt.guard'; 2 | export * from './jwt.strategy'; 3 | -------------------------------------------------------------------------------- /BE/libs/entity/src/enum/role.enum.ts: -------------------------------------------------------------------------------- 1 | export enum Role { 2 | OWNER = 'owner', 3 | EDITOR = 'editor', 4 | } 5 | -------------------------------------------------------------------------------- /BE/libs/publisher/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './publisher.module'; 2 | export * from './publisher.service'; 3 | -------------------------------------------------------------------------------- /client/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web32-BooMap/HEAD/client/public/logo.png -------------------------------------------------------------------------------- /client/src/constants/color.ts: -------------------------------------------------------------------------------- 1 | export const colors = ["#0053B5", "#64B5F6", "#6B95BC", "#A2D2FF", "#1d70a2"]; 2 | -------------------------------------------------------------------------------- /client/src/konva_mindmap/types/location.ts: -------------------------------------------------------------------------------- 1 | export type Location = { 2 | x: number; 3 | y: number; 4 | }; 5 | -------------------------------------------------------------------------------- /client/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web32-BooMap/HEAD/client/src/assets/logo.png -------------------------------------------------------------------------------- /BE/apps/api-server/src/strategies/index.ts: -------------------------------------------------------------------------------- 1 | export * from './github.strategy'; 2 | export * from './kakao.strategy'; 3 | -------------------------------------------------------------------------------- /client/public/thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web32-BooMap/HEAD/client/public/thumbnail.png -------------------------------------------------------------------------------- /client/src/assets/clovaX.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web32-BooMap/HEAD/client/src/assets/clovaX.png -------------------------------------------------------------------------------- /client/src/assets/lock.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web32-BooMap/HEAD/client/src/assets/lock.webp -------------------------------------------------------------------------------- /client/src/assets/warning.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web32-BooMap/HEAD/client/src/assets/warning.png -------------------------------------------------------------------------------- /client/src/types/mindmap.ts: -------------------------------------------------------------------------------- 1 | export type MindMap = { 2 | connectionId: string; 3 | role: "owner" | "editor"; 4 | }; 5 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/user/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './user.create.dto'; 2 | export * from './user.info.dto'; 3 | -------------------------------------------------------------------------------- /client/src/assets/notFound.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web32-BooMap/HEAD/client/src/assets/notFound.webp -------------------------------------------------------------------------------- /BE/apps/socket-server/src/pipes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './mindmap.validation.pipe'; 2 | export * from './ws.validation.pipe'; 3 | -------------------------------------------------------------------------------- /client/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /client/src/assets/dashbordIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web32-BooMap/HEAD/client/src/assets/dashbordIcon.png -------------------------------------------------------------------------------- /client/src/assets/kakao_login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web32-BooMap/HEAD/client/src/assets/kakao_login.png -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/mindmap/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create.mindmap.dto'; 2 | export * from './update.mindmap.dto'; 3 | -------------------------------------------------------------------------------- /BE/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /client/src/assets/github-mark-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web32-BooMap/HEAD/client/src/assets/github-mark-white.png -------------------------------------------------------------------------------- /client/src/constants/modeview.ts: -------------------------------------------------------------------------------- 1 | export const MODEVIEW = { 2 | VOICEUPLOAD: "voiceupload", 3 | LISTVIEW: "listview", 4 | TEXTUPLOAD: "textupload", 5 | }; 6 | -------------------------------------------------------------------------------- /client/src/types/auth.ts: -------------------------------------------------------------------------------- 1 | export type TokenRefresh = { 2 | accessToken: string; 3 | }; 4 | 5 | export type User = { 6 | email: string; 7 | nickname: string; 8 | }; 9 | -------------------------------------------------------------------------------- /client/src/utils/logging.ts: -------------------------------------------------------------------------------- 1 | export function logOnDev(log: string) { 2 | if (import.meta.env.VITE_APP_MODE === "DEVELOPMENT") { 3 | console.log(log); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /BE/apps/socket-server/src/modules/map/dto/node/update.mindmap.dto.ts: -------------------------------------------------------------------------------- 1 | import { NodeDto } from '..'; 2 | 3 | export class UpdateMindmapDto { 4 | [key: number]: NodeDto; 5 | } 6 | -------------------------------------------------------------------------------- /client/src/konva_mindmap/types/dimension.ts: -------------------------------------------------------------------------------- 1 | export type StageDimension = { 2 | scale: number; 3 | width: number; 4 | height: number; 5 | x: number; 6 | y: number; 7 | }; 8 | -------------------------------------------------------------------------------- /client/src/constants/uploadLimit.ts: -------------------------------------------------------------------------------- 1 | export const MAX_TEXT_UPLOAD_LIMIT = 15000; 2 | export const MIN_TEXT_UPLOAD_LIMIT = 500; 3 | export const FILE_UPLOAD_LIMIT = 1024 * 1024 * 100; 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .env.dev 3 | .env.development 4 | .env.production 5 | /BE/apps/api-server/node_modules 6 | /BE/apps/api-server/dist 7 | /BE/apps/socket-server/node_modules 8 | /BE/apps/socket-server/dist -------------------------------------------------------------------------------- /Dockerfile.nginx.dev: -------------------------------------------------------------------------------- 1 | # 웹서버 2 | FROM nginx:alpine 3 | 4 | WORKDIR / 5 | 6 | COPY ./nginx/dev.conf /etc/nginx/conf.d/default.conf 7 | 8 | EXPOSE 80 443 9 | 10 | CMD ["nginx", "-g", "daemon off;"] -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/node/dto/update.node.dto.ts: -------------------------------------------------------------------------------- 1 | export class UpdateNodeDto { 2 | id: number; 3 | keyword?: string; 4 | locationX?: number; 5 | locationY?: number; 6 | depth?: number; 7 | } 8 | -------------------------------------------------------------------------------- /client/src/konva_mindmap/utils/findRootNodeKey.ts: -------------------------------------------------------------------------------- 1 | import { NodeData } from "@/types/Node"; 2 | 3 | export function findRootNodeKey(data: NodeData) { 4 | return Number(Object.keys(data).find((key) => data[key].depth === 1)); 5 | } 6 | -------------------------------------------------------------------------------- /client/src/konva_mindmap/utils/throttle.ts: -------------------------------------------------------------------------------- 1 | let timer = null; 2 | export function throttle(fn: any, delay: number) { 3 | if (timer) return; 4 | timer = setTimeout(() => { 5 | fn(); 6 | timer = null; 7 | }, delay); 8 | } 9 | -------------------------------------------------------------------------------- /BE/libs/config/src/index.ts: -------------------------------------------------------------------------------- 1 | export { getWinstonConfig } from './logger.config'; 2 | export { getRedisConfig } from './redis.config'; 3 | export { getTypeOrmConfig } from './typeorm.config'; 4 | export { getJwtConfig } from './jwt.config'; 5 | -------------------------------------------------------------------------------- /client/src/utils/extractDate.ts: -------------------------------------------------------------------------------- 1 | export default function extractDate(date: Date): string { 2 | const year = date.getFullYear(); 3 | const month = date.getMonth() + 1; 4 | const day = date.getDate(); 5 | return `${year}.${month}.${day}`; 6 | } 7 | -------------------------------------------------------------------------------- /BE/apps/socket-server/src/modules/map/dto/ai.request.dto.ts: -------------------------------------------------------------------------------- 1 | import { Expose } from 'class-transformer'; 2 | import { IsString } from 'class-validator'; 3 | 4 | export class AiRequestDto { 5 | @Expose() 6 | @IsString() 7 | aiContent: string; 8 | } 9 | -------------------------------------------------------------------------------- /BE/apps/socket-server/src/modules/map/dto/node/delete.node.dto.ts: -------------------------------------------------------------------------------- 1 | import { Expose } from 'class-transformer'; 2 | import { IsArray } from 'class-validator'; 3 | 4 | export class DeleteNodeDto { 5 | @Expose() 6 | @IsArray() 7 | id: number[]; 8 | } 9 | -------------------------------------------------------------------------------- /BE/apps/socket-server/src/modules/map/dto/update.title.dto.ts: -------------------------------------------------------------------------------- 1 | import { Expose } from 'class-transformer'; 2 | import { IsString } from 'class-validator'; 3 | 4 | export class UpdateTitleDto { 5 | @Expose() 6 | @IsString() 7 | title: string; 8 | } 9 | -------------------------------------------------------------------------------- /BE/apps/socket-server/src/modules/map/dto/update.content.dto.ts: -------------------------------------------------------------------------------- 1 | import { Expose } from 'class-transformer'; 2 | import { IsString } from 'class-validator'; 3 | 4 | export class UpdateContentDto { 5 | @Expose() 6 | @IsString() 7 | content: string; 8 | } 9 | -------------------------------------------------------------------------------- /BE/apps/api-server/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /BE/apps/socket-server/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /client/src/hooks/useTokenRefresh.ts: -------------------------------------------------------------------------------- 1 | import { tokenRefresh } from "@/api/auth.api"; 2 | import { useMutation } from "@tanstack/react-query"; 3 | 4 | export const useTokenRefresh = () => { 5 | return useMutation({ 6 | mutationFn: tokenRefresh, 7 | }); 8 | }; 9 | -------------------------------------------------------------------------------- /client/src/konva_mindmap/types/dashboard.ts: -------------------------------------------------------------------------------- 1 | export type DashBoard = { 2 | id: number; 3 | connectionId: string; 4 | title: string; 5 | keyword: string[]; 6 | createDate: Date; 7 | modifiedDate: Date; 8 | ownerName: string; 9 | ownerId: number; 10 | }; 11 | -------------------------------------------------------------------------------- /client/src/api/dashboard.api.ts: -------------------------------------------------------------------------------- 1 | import { instance } from "@/api"; 2 | import { DashBoard } from "@/konva_mindmap/types/dashboard"; 3 | 4 | export async function getDashBoard(): Promise { 5 | const { data } = await instance.get("/dashboard"); 6 | return data; 7 | } 8 | -------------------------------------------------------------------------------- /client/src/konva_mindmap/utils/download.ts: -------------------------------------------------------------------------------- 1 | export function downloadURI(uri, name) { 2 | let link = document.createElement("a"); 3 | link.download = name; 4 | link.href = uri; 5 | document.body.appendChild(link); 6 | link.click(); 7 | document.body.removeChild(link); 8 | } 9 | -------------------------------------------------------------------------------- /BE/libs/config/src/jwt.config.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from '@nestjs/config'; 2 | import { JwtModuleOptions } from '@nestjs/jwt'; 3 | 4 | export const getJwtConfig = (configService: ConfigService): JwtModuleOptions => ({ 5 | secret: configService.get('JWT_SECRET'), 6 | }); 7 | -------------------------------------------------------------------------------- /BE/libs/jwt/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "outDir": "../../dist/libs/jwt" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/decorators/user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | 3 | export const User = createParamDecorator((data, ctx: ExecutionContext) => { 4 | const req = ctx.switchToHttp().getRequest(); 5 | return req.user; 6 | }); 7 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/ai/dto/ai.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsString } from 'class-validator'; 2 | 3 | export class AiDto { 4 | @IsNumber() 5 | mindmapId: number; 6 | 7 | @IsString() 8 | connectionId: string; 9 | 10 | @IsString() 11 | aiContent: string; 12 | } 13 | -------------------------------------------------------------------------------- /BE/libs/config/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "outDir": "../../dist/libs/config" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /BE/libs/entity/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "outDir": "../../dist/libs/entity" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /BE/apps/api-server/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": false, 5 | "outDir": "../../dist/apps/api-server" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /BE/libs/publisher/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "outDir": "../../dist/libs/publisher" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/mindmap/dto/create.mindmap.dto.ts: -------------------------------------------------------------------------------- 1 | import { Expose } from 'class-transformer'; 2 | import { IsString } from 'class-validator'; 3 | 4 | export class createMindmapDto { 5 | @IsString() 6 | @Expose() 7 | title: string; 8 | 9 | @Expose() 10 | aiCount: 5; 11 | } 12 | -------------------------------------------------------------------------------- /BE/apps/socket-server/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": false, 5 | "outDir": "../../dist/apps/socket-server" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /BE/libs/publisher/src/publisher.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common'; 2 | import { PublisherService } from './publisher.service'; 3 | 4 | @Global() 5 | @Module({ 6 | providers: [PublisherService], 7 | exports: [PublisherService], 8 | }) 9 | export class PublisherModule {} 10 | -------------------------------------------------------------------------------- /BE/apps/socket-server/src/modules/map/dto/node/location.dto.ts: -------------------------------------------------------------------------------- 1 | import { Expose } from 'class-transformer'; 2 | import { IsNumber } from 'class-validator'; 3 | 4 | export class LocationDto { 5 | @Expose() 6 | @IsNumber() 7 | x: number; 8 | 9 | @Expose() 10 | @IsNumber() 11 | y: number; 12 | } 13 | -------------------------------------------------------------------------------- /Dockerfile.fe.dev: -------------------------------------------------------------------------------- 1 | # 빌드 2 | FROM node:22-alpine AS build 3 | WORKDIR /app 4 | 5 | COPY ./client/package*.json ./ 6 | 7 | RUN apk update && apk add build-base g++ cairo-dev pango-dev giflib-dev 8 | RUN npm install 9 | 10 | COPY ./client ./ 11 | 12 | EXPOSE 5173 13 | 14 | CMD ["npm", "run", "dev"] 15 | 16 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/user/dto/user.create.dto.ts: -------------------------------------------------------------------------------- 1 | import { Expose } from 'class-transformer'; 2 | import { IsString } from 'class-validator'; 3 | 4 | export class UserCreateDto { 5 | @Expose() 6 | @IsString() 7 | email: string; 8 | 9 | @Expose() 10 | @IsString() 11 | name: string; 12 | } 13 | -------------------------------------------------------------------------------- /BE/Dockerfile.api.dev: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine 2 | WORKDIR /app 3 | 4 | RUN npm install -g @nestjs/cli 5 | 6 | COPY package*.json ./ 7 | COPY nest-cli.json ./ 8 | COPY tsconfig*.json ./ 9 | 10 | COPY apps/api-server/package*.json ./apps/api-server/ 11 | COPY libs ./libs 12 | 13 | RUN npm install 14 | 15 | EXPOSE 3000 16 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/user/dto/user.info.dto.ts: -------------------------------------------------------------------------------- 1 | import { Expose } from 'class-transformer'; 2 | import { IsEmail, IsString } from 'class-validator'; 3 | 4 | export class UserInfoDto { 5 | @Expose() 6 | @IsEmail() 7 | email: string; 8 | 9 | @Expose() 10 | @IsString() 11 | name: string; 12 | } 13 | -------------------------------------------------------------------------------- /client/src/api/fetchHooks/useDashBoard.ts: -------------------------------------------------------------------------------- 1 | import { getDashBoard } from "@/api/dashboard.api"; 2 | import { useSuspenseQuery } from "@tanstack/react-query"; 3 | 4 | export default function useDashBoard() { 5 | return useSuspenseQuery({ queryKey: ["dashboard"], queryFn: getDashBoard, retry: 2, refetchOnMount: true }); 6 | } 7 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/ai/dto/audio.upload.dto.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { IsNumber, IsString } from 'class-validator'; 3 | 4 | export class AudioUploadDto { 5 | @IsNumber() 6 | @Type(() => Number) 7 | mindmapId: number; 8 | 9 | @IsString() 10 | connectionId: string; 11 | } 12 | -------------------------------------------------------------------------------- /BE/Dockerfile.socket.dev: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine 2 | WORKDIR /app 3 | 4 | RUN npm install -g @nestjs/cli 5 | 6 | COPY package*.json ./ 7 | COPY nest-cli.json ./ 8 | COPY tsconfig*.json ./ 9 | 10 | COPY apps/socket-server/package*.json ./apps/socket-server/ 11 | COPY libs ./libs 12 | 13 | RUN npm install 14 | 15 | EXPOSE 4000 16 | -------------------------------------------------------------------------------- /client/src/components/common/Spinner.tsx: -------------------------------------------------------------------------------- 1 | export default function Spinner() { 2 | return ( 3 |
4 |
5 |
6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /client/src/App.tsx: -------------------------------------------------------------------------------- 1 | import MainPage from "@/pages/Main"; 2 | import "./App.css"; 3 | import { Suspense } from "react"; 4 | import Spinner from "@/components/common/Spinner"; 5 | 6 | export default function App() { 7 | return ( 8 | }> 9 | 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /BE/libs/entity/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Mindmap } from './mindmap.entity'; 2 | import { User } from './user.entity'; 3 | import { Node } from './node.entity'; 4 | import { UserMindmapRole } from './user.mindmap.role'; 5 | 6 | export const entities = [Mindmap, User, Node, UserMindmapRole]; 7 | 8 | export { Mindmap, User, Node, UserMindmapRole }; 9 | -------------------------------------------------------------------------------- /BE/apps/socket-server/src/modules/subscriber/subscriber.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { SubscriberService } from './subscriber.service'; 3 | import { MapModule } from '../map/map.module'; 4 | 5 | @Module({ 6 | imports: [MapModule], 7 | providers: [SubscriberService], 8 | }) 9 | export class SubscriberModule {} 10 | -------------------------------------------------------------------------------- /client/src/store/useSideBar.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | interface SideBarStore { 4 | open: boolean; 5 | toggleSideBar: () => void; 6 | } 7 | 8 | export const useSideBar = create((set) => ({ 9 | open: true, 10 | toggleSideBar: () => 11 | set((state) => ({ 12 | open: !state.open, 13 | })), 14 | })); 15 | -------------------------------------------------------------------------------- /BE/libs/jwt/src/optional.jwt.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class OptionalJwtGuard extends AuthGuard('jwt') { 6 | handleRequest(err: any, user: any) { 7 | if (err || !user) { 8 | return null; 9 | } 10 | return user; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /client/src/hooks/useModal.ts: -------------------------------------------------------------------------------- 1 | import { MouseEvent, useState } from "react"; 2 | 3 | export default function useModal() { 4 | const [open, setOpen] = useState(false); 5 | function openModal() { 6 | setOpen(true); 7 | } 8 | function closeModal() { 9 | setOpen(false); 10 | } 11 | 12 | return { open, openModal, closeModal }; 13 | } 14 | -------------------------------------------------------------------------------- /client/src/pages/auth/index.tsx: -------------------------------------------------------------------------------- 1 | import Authorize from "@/components/Authorize"; 2 | import Error from "@/components/common/Error"; 3 | import { ErrorBoundary } from "react-error-boundary"; 4 | 5 | export default function AuthorizeCallback() { 6 | return ( 7 | }> 8 | 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /client/src/hooks/useAccordion.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | export default function useAccordion() { 4 | const [open, setOpen] = useState(false); 5 | function handleAccordion() { 6 | setOpen(!open); 7 | } 8 | function openAccordion() { 9 | if (!open) setOpen(!open); 10 | } 11 | return { open, handleAccordion, openAccordion }; 12 | } 13 | -------------------------------------------------------------------------------- /BE/apps/socket-server/src/modules/map/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './node/location.dto'; 2 | export * from './node/create.node.dto'; 3 | export * from './node/update.mindmap.dto'; 4 | export * from './node/delete.node.dto'; 5 | export * from './node/update.node.dto'; 6 | export * from './ai.request.dto'; 7 | export * from './update.content.dto'; 8 | export * from './update.title.dto'; 9 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /BE/apps/socket-server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "socket-server", 3 | "version": "1.0.0", 4 | "license": "ISC", 5 | "description": "", 6 | "dependencies": { 7 | "@nestjs/platform-socket.io": "^10.4.8", 8 | "@nestjs/websockets": "^10.4.8", 9 | "@types/socket.io": "^3.0.1", 10 | "passport-github2": "^0.1.12", 11 | "passport-kakao": "^1.0.1" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## PR 작성 전 체크 리스트 2 | 3 | 타이틀은 “[단위-번호] - 상세 내용” 4 | ex ) [feature-3-1] - 상세 내용 5 | 완료 작업 목록을 기록합니다. (WHAT) 6 | 어떤 작업을 완료했는지 드러나야 합니다. 7 | 브랜치 확인할 것. main이나 dev로 보내면 안 됨 :x: feature-(적합한 번호)로 보내야 됨 8 | PR 작성 후 충돌이 안 나는지 확인할 것!! 9 | 적절한 라벨을 선택하세요. 10 | 이후 PR에 맞는 issue를 선택하세요 11 | <위 내용은 모두 삭제하고 PR 보내세요> 12 | ---- 절취선 ---- 13 | 14 | ## 작업 내용 15 | 16 | ## 논의하고 싶은 내용 17 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/node/node.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { NodeService } from './node.service'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { Node } from '@app/entity'; 5 | 6 | @Module({ 7 | imports: [TypeOrmModule.forFeature([Node])], 8 | providers: [NodeService], 9 | exports: [NodeService], 10 | }) 11 | export class NodeModule {} 12 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/node/dto/node.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsArray, IsNumber, IsObject, IsString } from 'class-validator'; 2 | 3 | export class NodeDto { 4 | @IsNumber() 5 | id: number; 6 | 7 | @IsString() 8 | keyword: string; 9 | 10 | @IsNumber() 11 | depth: number; 12 | 13 | @IsObject() 14 | location: { x: number; y: number }; 15 | 16 | @IsArray() 17 | children: number[]; 18 | } 19 | -------------------------------------------------------------------------------- /client/src/konva_mindmap/utils/findLastLeafNode.ts: -------------------------------------------------------------------------------- 1 | import { NodeData } from "@/types/Node"; 2 | 3 | export function findLastLeafNode(data: NodeData, nodeId: number) { 4 | const currentNode = data[nodeId]; 5 | 6 | if (!currentNode.children || currentNode.children.length === 0) { 7 | return nodeId; 8 | } 9 | 10 | return findLastLeafNode(data, currentNode.children[currentNode.children.length - 1]); 11 | } 12 | -------------------------------------------------------------------------------- /client/src/types/store.ts: -------------------------------------------------------------------------------- 1 | import { AuthSlice } from "@/store/createAuthSlice"; 2 | import { MindMapOwnershipSlice } from "@/store/createMindMapOwnershipSlice"; 3 | import { RoleSlice } from "@/store/createRoleSlice"; 4 | import { SharedSlice } from "@/store/createSharedSlice"; 5 | import { SocketSlice } from "@/store/createSocketSlice"; 6 | 7 | export type ConnectionStore = RoleSlice & MindMapOwnershipSlice & SharedSlice & SocketSlice & AuthSlice; 8 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/mindmap/dto/update.mindmap.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsOptional, IsString } from 'class-validator'; 2 | 3 | export class UpdateMindmapDto { 4 | @IsOptional() 5 | @IsString() 6 | title?: string; 7 | 8 | @IsOptional() 9 | @IsNumber() 10 | aiCount?: number; 11 | 12 | @IsOptional() 13 | @IsString() 14 | content?: string; 15 | 16 | @IsOptional() 17 | @IsString() 18 | aiContent?: string; 19 | } 20 | -------------------------------------------------------------------------------- /client/src/hooks/useContent.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | export default function useContent() { 4 | const [content, setContent] = useState(""); 5 | function updateContent(updatedContent: string) { 6 | setContent(updatedContent); 7 | } 8 | function initializeContent(initialData) { 9 | if (initialData.content) updateContent(initialData.content); 10 | } 11 | 12 | return { content, updateContent, initializeContent }; 13 | } 14 | -------------------------------------------------------------------------------- /client/src/hooks/useMindMapTitle.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | export default function useMindMapTitle() { 4 | const [title, setTitle] = useState("제목없는 마인드맵"); 5 | 6 | function updateTitle(newTitle: string) { 7 | setTitle(newTitle); 8 | } 9 | 10 | function initializeTitle(initializeData) { 11 | if (initializeData.title) setTitle(initializeData.title); 12 | } 13 | 14 | return { title, updateTitle, initializeTitle }; 15 | } 16 | -------------------------------------------------------------------------------- /client/src/components/Dashboard/index.tsx: -------------------------------------------------------------------------------- 1 | import { useConnectionStore } from "@/store/useConnectionStore"; 2 | import GuestDashBoard from "./GuestDashBoard"; 3 | import UserDashBoard from "./UserDashBoard"; 4 | 5 | export default function DashBoard() { 6 | const loggedIn = useConnectionStore((state) => state.token); 7 | return ( 8 | <> 9 |
{!!loggedIn ? : }
10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /client/src/utils/formData.ts: -------------------------------------------------------------------------------- 1 | export function audioFormData(file: File, mindmapId: string, connectionId: string) { 2 | const formData = new FormData(); 3 | 4 | const encodedFileName = encodeURIComponent(file.name); 5 | const encodedFile = new File([file], encodedFileName, { type: file.type }); 6 | 7 | formData.append("aiAudio", encodedFile); 8 | formData.append("mindmapId", mindmapId); 9 | formData.append("connectionId", connectionId); 10 | 11 | return formData; 12 | } 13 | -------------------------------------------------------------------------------- /client/src/konva_mindmap/utils/checkNodeCount.ts: -------------------------------------------------------------------------------- 1 | import { NodeData } from "@/types/Node"; 2 | 3 | export function checkNodeCount(data: NodeData, id: number) { 4 | const nodeData = data[id]; 5 | const childrenCount = nodeData.children.length; 6 | if (childrenCount >= 15) return false; 7 | return true; 8 | } 9 | 10 | export function checkAllNodeCount(data: NodeData) { 11 | const allNodeCount = Object.keys(data).length; 12 | if (allNodeCount >= 150) return false; 13 | return true; 14 | } 15 | -------------------------------------------------------------------------------- /BE/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSpacing": true, 4 | "endOfLine": "auto", 5 | "jsxBracketSameLine": false, 6 | "jsxSingleQuote": false, 7 | "printWidth": 120, 8 | "quoteProps": "as-needed", 9 | "semi": true, 10 | "singleQuote": true, 11 | "tabWidth": 2, 12 | "trailingComma": "all", 13 | "useTabs": false, 14 | "overrides": [ 15 | { 16 | "files": "*.json", 17 | "options": { 18 | "printWidth": 200 19 | } 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /client/src/hooks/useWindowEventListener.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | export default function useWindowEventListener( 4 | eventType: T, 5 | eventHandler: (event: WindowEventMap[T]) => void, 6 | ) { 7 | useEffect(() => { 8 | window.addEventListener(eventType, eventHandler as EventListener); 9 | return () => { 10 | window.removeEventListener(eventType, eventHandler as EventListener); 11 | }; 12 | }, [eventType, eventHandler]); 13 | } 14 | -------------------------------------------------------------------------------- /Dockerfile.nginx: -------------------------------------------------------------------------------- 1 | # 빌드 2 | FROM node:22-alpine AS build 3 | WORKDIR /app 4 | 5 | COPY ./client/package*.json ./ 6 | 7 | RUN apk update && apk add build-base g++ cairo-dev pango-dev giflib-dev 8 | RUN npm install 9 | 10 | COPY ./client ./ 11 | 12 | RUN npm run build 13 | 14 | # 웹서버 15 | FROM nginx:alpine 16 | 17 | WORKDIR / 18 | 19 | COPY ./nginx/default.conf /etc/nginx/conf.d/default.conf 20 | 21 | COPY --from=build /app/dist ./var/www/html 22 | 23 | EXPOSE 80 443 24 | 25 | CMD ["nginx", "-g", "daemon off;"] -------------------------------------------------------------------------------- /client/src/api/fetchHooks/useDeleteMindMap.ts: -------------------------------------------------------------------------------- 1 | import { deleteMindMap } from "@/api/mindmap.api"; 2 | import { useMutation, useQueryClient } from "@tanstack/react-query"; 3 | 4 | export function useDeleteMindMap({ mindMapId, onError }) { 5 | const queryClient = useQueryClient(); 6 | return useMutation({ 7 | mutationFn: () => deleteMindMap(mindMapId), 8 | mutationKey: ["dashboard"], 9 | onSuccess: () => queryClient.invalidateQueries({ queryKey: ["dashboard"] }), 10 | onError, 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /client/src/hooks/useGroupSelect.ts: -------------------------------------------------------------------------------- 1 | import Konva from "konva"; 2 | import { useState } from "react"; 3 | 4 | export default function useGroupSelect() { 5 | const [selectedGroup, setSelectedGroup] = useState([]); 6 | function groupSelect(group: Konva.Group[]) { 7 | const selectedNodes = group.map((node) => node.attrs.id); 8 | setSelectedGroup(selectedNodes); 9 | } 10 | function groupRelease() { 11 | setSelectedGroup([]); 12 | } 13 | return { groupSelect, groupRelease, selectedGroup }; 14 | } 15 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/subscriber/subscriber.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { SubscriberService } from './subscriber.service'; 3 | import { NodeModule } from '../node/node.module'; 4 | import { MindmapModule } from '../mindmap/mindmap.module'; 5 | import { AiModule } from '../ai/ai.module'; 6 | 7 | @Module({ 8 | imports: [NodeModule, MindmapModule, AiModule], 9 | providers: [SubscriberService], 10 | exports: [SubscriberService], 11 | }) 12 | export class SubscriberModule {} 13 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/dashboard/dashboard.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { DashboardService } from './dashboard.service'; 3 | import { DashboardController } from './dashboard.controller'; 4 | import { MindmapModule } from '../mindmap/mindmap.module'; 5 | import { NodeModule } from '../node/node.module'; 6 | 7 | @Module({ 8 | controllers: [DashboardController], 9 | imports: [MindmapModule, NodeModule], 10 | providers: [DashboardService], 11 | }) 12 | export class DashboardModule {} 13 | -------------------------------------------------------------------------------- /client/src/hooks/useMinutes.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | export default function useMinutes() { 4 | const [showMinutes, setShowMinutes] = useState(false); 5 | const [isAnimating, setIsAnimating] = useState(true); 6 | 7 | function handleShowMinutes() { 8 | setShowMinutes(!showMinutes); 9 | setIsAnimating(true); 10 | } 11 | 12 | function handleIsAnimating() { 13 | setIsAnimating(false); 14 | } 15 | 16 | return { showMinutes, handleShowMinutes, isAnimating, handleIsAnimating }; 17 | } 18 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { UserService } from './user.service'; 3 | import { UserController } from './user.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { User, UserMindmapRole } from '@app/entity'; 6 | 7 | @Module({ 8 | imports: [TypeOrmModule.forFeature([User, UserMindmapRole])], 9 | controllers: [UserController], 10 | providers: [UserService], 11 | exports: [UserService], 12 | }) 13 | export class UserModule {} 14 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/connection/connection.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConnectionController } from './connection.controller'; 3 | import { ConnectionService } from './connection.service'; 4 | import { UserModule } from '../user/user.module'; 5 | import { MindmapModule } from '../mindmap/mindmap.module'; 6 | 7 | @Module({ 8 | controllers: [ConnectionController], 9 | imports: [UserModule, MindmapModule], 10 | providers: [ConnectionService], 11 | }) 12 | export class ConnectionModule {} 13 | -------------------------------------------------------------------------------- /BE/apps/api-server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api-server", 3 | "version": "1.0.0", 4 | "license": "ISC", 5 | "dependencies": { 6 | "@nestjs/mapped-types": "*", 7 | "@nestjs/passport": "^10.0.3", 8 | "passport": "^0.7.0", 9 | "passport-github2": "^0.1.12", 10 | "passport-jwt": "^4.0.1", 11 | "passport-kakao": "^1.0.1", 12 | "uuid": "^11.0.3" 13 | }, 14 | "devDependencies": { 15 | "@types/passport-github2": "^1.2.9", 16 | "@types/passport-jwt": "^4.0.1", 17 | "@types/passport-kakao": "^1.0.3" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/ai/ai.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AiService } from './ai.service'; 3 | 4 | describe('AiService', () => { 5 | let service: AiService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [AiService], 10 | }).compile(); 11 | 12 | service = module.get(AiService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/ai/ai.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AiService } from './ai.service'; 3 | import { HttpModule } from '@nestjs/axios'; 4 | import { NodeModule } from '../node/node.module'; 5 | import { AiController } from './ai.controller'; 6 | import { AudioFileValidationPipe } from '../../pipes'; 7 | 8 | @Module({ 9 | imports: [HttpModule, NodeModule], 10 | providers: [AiService, AudioFileValidationPipe], 11 | exports: [AiService], 12 | controllers: [AiController], 13 | }) 14 | export class AiModule {} 15 | -------------------------------------------------------------------------------- /BE/apps/socket-server/src/modules/map/map.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MapGateway } from './map.gateway'; 3 | import { MapService } from './map.service'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { Node } from '@app/entity'; 6 | import { WsOptionalJwtGuard } from '../../guards/ws.jwt.auth.guard'; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([Node])], 10 | providers: [MapGateway, MapService, WsOptionalJwtGuard], 11 | exports: [MapGateway, MapService], 12 | }) 13 | export class MapModule {} 14 | -------------------------------------------------------------------------------- /client/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["prettier-plugin-tailwindcss"], 3 | "arrowParens": "always", 4 | "bracketSpacing": true, 5 | "endOfLine": "auto", 6 | "jsxBracketSameLine": false, 7 | "jsxSingleQuote": false, 8 | "printWidth": 120, 9 | "quoteProps": "as-needed", 10 | "semi": true, 11 | "singleQuote": false, 12 | "tabWidth": 2, 13 | "trailingComma": "all", 14 | "useTabs": false, 15 | "overrides": [ 16 | { 17 | "files": "*.json", 18 | "options": { 19 | "printWidth": 200 20 | } 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /BE/apps/socket-server/src/modules/map/map.gateway.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { MapGateway } from './map.gateway'; 3 | 4 | describe('MapGateway', () => { 5 | let gateway: MapGateway; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [MapGateway], 10 | }).compile(); 11 | 12 | gateway = module.get(MapGateway); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(gateway).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /BE/apps/socket-server/src/modules/map/map.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { MapService } from './map.service'; 3 | 4 | describe('MapService', () => { 5 | let service: MapService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [MapService], 10 | }).compile(); 11 | 12 | service = module.get(MapService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/auth/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AuthService } from './auth.service'; 3 | 4 | describe('AuthService', () => { 5 | let service: AuthService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [AuthService], 10 | }).compile(); 11 | 12 | service = module.get(AuthService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/node/node.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { NodeService } from './node.service'; 3 | 4 | describe('NodeService', () => { 5 | let service: NodeService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [NodeService], 10 | }).compile(); 11 | 12 | service = module.get(NodeService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/user/user.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { UserService } from './user.service'; 3 | 4 | describe('UserService', () => { 5 | let service: UserService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [UserService], 10 | }).compile(); 11 | 12 | service = module.get(UserService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /client/src/components/common/aiSpinner.tsx: -------------------------------------------------------------------------------- 1 | import robot from "@/assets/lottie/robot.json"; 2 | import Lottie from "lottie-react"; 3 | 4 | export default function AiSpinner() { 5 | return ( 6 |
7 |
8 | 9 |

AI가 회의록을 마인드맵으로 변환하고 있어요

10 |
11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /client/src/konva_mindmap/components/ConnectedLine.tsx: -------------------------------------------------------------------------------- 1 | import { Location } from "@/konva_mindmap/types/location"; 2 | import { getLinePoints } from "@/konva_mindmap/utils/points"; 3 | import { Line } from "react-konva"; 4 | 5 | type ConnectedLineProps = { 6 | from: Location; 7 | to: Location; 8 | fromRadius: number; 9 | toRadius: number; 10 | }; 11 | 12 | export default function ConnectedLine({ from, to, fromRadius, toRadius }: ConnectedLineProps) { 13 | return ; 14 | } 15 | -------------------------------------------------------------------------------- /client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | 4 | // https://vite.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | server: { 8 | open: false, 9 | host: true, 10 | port: 5173, 11 | }, 12 | resolve: { 13 | alias: [ 14 | { 15 | find: "@", 16 | replacement: "/src", 17 | }, 18 | ], 19 | }, 20 | build: { 21 | outDir: "dist", 22 | }, 23 | base: "/", 24 | test: { 25 | environment: "jsdom", 26 | globals: true, 27 | }, 28 | }); 29 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/ai/ai.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AiController } from './ai.controller'; 3 | 4 | describe('AiController', () => { 5 | let controller: AiController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [AiController], 10 | }).compile(); 11 | 12 | controller = module.get(AiController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /client/src/components/MindMapMainSection/ControlSection/ListView/index.tsx: -------------------------------------------------------------------------------- 1 | import NodeList from "@/components/MindMapMainSection/ControlSection/ListView/NodeList"; 2 | import { useNodeListContext } from "@/store/NodeListProvider"; 3 | import { Node } from "@/types/Node"; 4 | 5 | export default function ListView() { 6 | const { data } = useNodeListContext(); 7 | if (!Object.keys(data).length) return

새로운 브레인스토밍을 시작해보세요

; 8 | const root: Node = data[Object.keys(data)[0]]; 9 | 10 | return
{Object.keys(data).length >= 1 && }
; 11 | } 12 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/connection/dto/connection.query.dto.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from 'class-transformer'; 2 | import { IsIn, IsNumber, IsString, ValidateIf } from 'class-validator'; 3 | 4 | export class ConnectionQueryDto { 5 | @IsString() 6 | @IsIn(['mindmap', 'connection']) 7 | type: string; 8 | 9 | @Transform(({ value, obj }) => { 10 | return obj.type === 'mindmap' ? Number(value) : value; 11 | }) 12 | @ValidateIf((o) => o.type === 'mindmap') 13 | @IsNumber() 14 | @ValidateIf((o) => o.type === 'connection') 15 | @IsString() 16 | id: string | number; 17 | } 18 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/mindmap/mindmap.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { MindmapService } from './mindmap.service'; 3 | 4 | describe('MindmapService', () => { 5 | let service: MindmapService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [MindmapService], 10 | }).compile(); 11 | 12 | service = module.get(MindmapService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /BE/libs/publisher/src/publisher.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { PublisherService } from './publisher.service'; 3 | 4 | describe('PublisherService', () => { 5 | let service: PublisherService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [PublisherService], 10 | }).compile(); 11 | 12 | service = module.get(PublisherService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /client/src/components/Modal/OfflineModal.tsx: -------------------------------------------------------------------------------- 1 | import Modal from "../common/Modal"; 2 | 3 | export default function OfflineModal({ open, closeModal }) { 4 | return ( 5 | 6 |
7 |

오프라인 상태입니다

8 |

인터넷 연결을 확인해주세요.

9 |

작업 내역이 저장되지 않을 수 있습니다

10 |
11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/dashboard/dashboard.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { DashboardService } from './dashboard.service'; 3 | 4 | describe('DashboardService', () => { 5 | let service: DashboardService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [DashboardService], 10 | }).compile(); 11 | 12 | service = module.get(DashboardService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /client/src/components/common/Toast/ToastContainer.tsx: -------------------------------------------------------------------------------- 1 | import Toast from "./Toast"; 2 | 3 | export default function ToastContainer({ toasts, setToasts }) { 4 | const removeToast = (id) => { 5 | setToasts((prevToasts) => prevToasts.filter((toast) => toast.id !== id)); 6 | }; 7 | return ( 8 |
9 | {toasts.toReversed().map((toast) => ( 10 | removeToast(toast.id)} /> 11 | ))} 12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /BE/Dockerfile.api: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine AS build 2 | WORKDIR /app 3 | 4 | COPY ./package*.json ./ 5 | COPY ./apps/api-server/package*.json ./apps/api-server/ 6 | 7 | RUN npm install 8 | 9 | COPY . . 10 | RUN npx nest build libs 11 | RUN npx nest build api-server 12 | 13 | FROM node:22-alpine AS production 14 | WORKDIR /app 15 | 16 | COPY --from=build /app/package*.json ./ 17 | COPY --from=build /app/apps/api-server/package*.json ./apps/api-server/ 18 | 19 | RUN npm install --only=production 20 | 21 | COPY --from=build /app/dist/apps/api-server ./dist 22 | 23 | EXPOSE 3000 24 | 25 | CMD ["node", "dist/main.js"] 26 | 27 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/subscriber/subscriber.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { SubscriberService } from './subscriber.service'; 3 | 4 | describe('SubscriberService', () => { 5 | let service: SubscriberService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [SubscriberService], 10 | }).compile(); 11 | 12 | service = module.get(SubscriberService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /BE/apps/socket-server/src/modules/subscriber/subscriber.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { SubscriberService } from './subscriber.service'; 3 | 4 | describe('SubscriberService', () => { 5 | let service: SubscriberService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [SubscriberService], 10 | }).compile(); 11 | 12 | service = module.get(SubscriberService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AuthService } from './auth.service'; 3 | import { AuthController } from './auth.controller'; 4 | import { PassportModule } from '@nestjs/passport'; 5 | import { KakaoStrategy, GithubStrategy } from '../../strategies'; 6 | import { UserModule } from '../user/user.module'; 7 | import { JwtStrategy } from '@app/jwt'; 8 | 9 | @Module({ 10 | imports: [PassportModule, UserModule], 11 | controllers: [AuthController], 12 | providers: [AuthService, GithubStrategy, KakaoStrategy, JwtStrategy], 13 | }) 14 | export class AuthModule {} 15 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/mindmap/mindmap.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { MindmapController } from './mindmap.controller'; 3 | 4 | describe('MindmapController', () => { 5 | let controller: MindmapController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [MindmapController], 10 | }).compile(); 11 | 12 | controller = module.get(MindmapController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /client/src/hooks/useAiCount.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | export type AiCountHook = { 4 | aiCount: number; 5 | initializeAiCount: (initialData) => void; 6 | decreaseAiCount: () => void; 7 | }; 8 | 9 | export default function useAiCount(): AiCountHook { 10 | const [aiCount, setAiCount] = useState(0); 11 | 12 | function initializeAiCount(initialData) { 13 | setAiCount(parseInt(initialData.aiCount)); 14 | } 15 | 16 | function decreaseAiCount() { 17 | setAiCount((prev) => prev - 1); 18 | } 19 | return { 20 | aiCount, 21 | initializeAiCount, 22 | decreaseAiCount, 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /BE/Dockerfile.socket: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine AS build 2 | WORKDIR /app 3 | 4 | COPY ./package*.json ./ 5 | COPY ./apps/socket-server/package*.json ./apps/socket-server/ 6 | 7 | RUN npm install 8 | 9 | COPY . . 10 | RUN npx nest build libs 11 | RUN npx nest build socket-server 12 | 13 | FROM node:22-alpine AS production 14 | WORKDIR /app 15 | 16 | COPY --from=build /app/package*.json ./ 17 | COPY --from=build /app/apps/socket-server/package*.json ./apps/socket-server/ 18 | 19 | RUN npm install --only=production 20 | 21 | COPY --from=build /app/dist/apps/socket-server ./dist 22 | 23 | EXPOSE 4000 24 | 25 | CMD ["node", "dist/main.js"] 26 | 27 | -------------------------------------------------------------------------------- /client/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ["./index.html", "./src/**/*.{js,ts,tsx,jsx}"], 4 | theme: { 5 | extend: { 6 | colors: { 7 | bm: { 8 | blue: "#2563EB", 9 | purple: "#98A4EE", 10 | }, 11 | grayscale: { 12 | 800: "#2F3044", 13 | 700: "#343549", 14 | 600: "#424358", 15 | 500: "#565868", 16 | 400: "#7e7e96", 17 | 300: "#9c9e9f", 18 | 200: "#d5d5d5", 19 | 100: "#eaf1fb", 20 | }, 21 | }, 22 | }, 23 | }, 24 | plugins: [], 25 | }; 26 | -------------------------------------------------------------------------------- /.github/workflows/be-ci.yaml: -------------------------------------------------------------------------------- 1 | name: Backend CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - dev 7 | paths: 8 | - "server/**" 9 | 10 | jobs: 11 | be-test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v3 16 | 17 | - name: Set up Node.js 18 | uses: actions/setup-node@v2 19 | with: 20 | node-version: "22" 21 | 22 | - name: Install backend dependencies 23 | working-directory: server 24 | run: npm install 25 | 26 | - name: Run backend tests 27 | working-directory: server 28 | run: npm test 29 | -------------------------------------------------------------------------------- /client/src/components/MindMapMainSection/ControlSection/UploadAvailabilityArrowBox.tsx: -------------------------------------------------------------------------------- 1 | import ArrowBox from "@/components/common/ArrowBox"; 2 | 3 | export default function UploadAvailabilityArrowBox({ content }: { content: string }) { 4 | return ( 5 | <> 6 | {content && ( 7 | 12 |

{content}

13 |
14 | )} 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /client/src/types/Node.ts: -------------------------------------------------------------------------------- 1 | import { Location } from "@/konva_mindmap/types/location"; 2 | import Konva from "konva"; 3 | import { RefObject } from "react"; 4 | 5 | export type Node = { 6 | id: number; 7 | keyword: string; 8 | depth: number; 9 | location: Location; 10 | children: number[]; 11 | newNode?: boolean; 12 | }; 13 | 14 | export type NodeData = Record; 15 | 16 | export type SelectedNode = { 17 | nodeId?: number; 18 | parentNodeId?: number; 19 | }; 20 | 21 | export type NodeProps = { 22 | data: NodeData; 23 | parentNode?: Node; 24 | node: Node; 25 | depth: number; 26 | dragmode: boolean; 27 | scale: number; 28 | }; 29 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/user/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, UnauthorizedException, UseGuards } from '@nestjs/common'; 2 | import { UserService } from './user.service'; 3 | import { AuthGuard } from '@nestjs/passport'; 4 | import { User } from '../../decorators'; 5 | 6 | @Controller('user') 7 | export class UserController { 8 | constructor(private readonly userService: UserService) {} 9 | 10 | @Get('info') 11 | @UseGuards(AuthGuard('jwt')) 12 | async getUserInfo(@User() user) { 13 | try { 14 | return await this.userService.getUserInfo(user.id); 15 | } catch { 16 | throw new UnauthorizedException(); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /client/src/hooks/useSection.ts: -------------------------------------------------------------------------------- 1 | import { useSearchParams } from "react-router-dom"; 2 | 3 | export type SectionViewMode = "textupload" | "voiceupload" | "dashboard" | "listview"; 4 | export default function useSection() { 5 | const [searchParams, setSearchParams] = useSearchParams(); 6 | 7 | function handleViewMode(mode: SectionViewMode) { 8 | if (getmode() === mode) return; 9 | searchParams.set("mode", mode); 10 | setSearchParams(searchParams); 11 | } 12 | 13 | function getmode() { 14 | return searchParams.get("mode") ?? "dashboard"; 15 | } 16 | 17 | return { 18 | searchParams, 19 | getmode, 20 | handleViewMode, 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /client/src/konva_mindmap/hooks/useLayerEvent.ts: -------------------------------------------------------------------------------- 1 | import Konva from "konva"; 2 | import { useEffect, useRef } from "react"; 3 | 4 | type useLayerEventProps = [eventName: string, eventHandler: () => void][]; 5 | 6 | export default function useLayerEvent(events: useLayerEventProps) { 7 | const layer = useRef(); 8 | useEffect(() => { 9 | events.forEach(([eventName, eventHandler]) => { 10 | layer.current?.on(eventName, eventHandler); 11 | }); 12 | return () => { 13 | events.forEach(([eventName, eventHandler]) => { 14 | layer.current?.off(eventName, eventHandler); 15 | }); 16 | }; 17 | }, []); 18 | return layer; 19 | } 20 | -------------------------------------------------------------------------------- /BE/libs/jwt/src/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { PassportStrategy } from '@nestjs/passport'; 4 | import { ExtractJwt, Strategy } from 'passport-jwt'; 5 | 6 | @Injectable() 7 | export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { 8 | constructor(private configService: ConfigService) { 9 | super({ 10 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 11 | secretOrKey: configService.get('JWT_SECRET'), 12 | }); 13 | } 14 | 15 | async validate(payload: any) { 16 | return { id: payload.id, email: payload.email }; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/mindmap/mindmap.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MindmapService } from './mindmap.service'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { Mindmap } from '@app/entity'; 5 | import { MindmapController } from './mindmap.controller'; 6 | import { NodeModule } from '../node/node.module'; 7 | import { UserMindmapRole } from '@app/entity/user.mindmap.role'; 8 | 9 | @Module({ 10 | imports: [TypeOrmModule.forFeature([Mindmap, UserMindmapRole]), NodeModule], 11 | providers: [MindmapService], 12 | exports: [MindmapService], 13 | controllers: [MindmapController], 14 | }) 15 | export class MindmapModule {} 16 | -------------------------------------------------------------------------------- /BE/apps/socket-server/src/modules/map/dto/node/create.node.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator'; 2 | import { LocationDto } from '..'; 3 | import { Expose, Type } from 'class-transformer'; 4 | 5 | export class CreateNodeDto { 6 | @Expose() 7 | @IsNumber() 8 | id: number; 9 | 10 | @Expose() 11 | @IsString() 12 | keyword: string; 13 | 14 | @Expose() 15 | @IsNumber() 16 | depth: number; 17 | 18 | @Expose() 19 | @IsObject() 20 | @ValidateNested() 21 | @Type(() => LocationDto) 22 | location: LocationDto; 23 | 24 | @Expose() 25 | @IsOptional() 26 | @IsNumber() 27 | parentId?: number; 28 | } 29 | -------------------------------------------------------------------------------- /client/src/components/common/ArrowBox.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | export default function ArrowBox({ 4 | children, 5 | containerClassName, 6 | boxClassName, 7 | arrowClassName, 8 | }: { 9 | children: ReactNode; 10 | containerClassName: string; 11 | boxClassName: string; 12 | arrowClassName: string; 13 | }) { 14 | return ( 15 |
16 |
17 | {children} 18 |
19 |
20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /BE/libs/publisher/src/publisher.service.ts: -------------------------------------------------------------------------------- 1 | import { RedisService } from '@liaoliaots/nestjs-redis'; 2 | import { Injectable, Logger } from '@nestjs/common'; 3 | import Redis from 'ioredis'; 4 | 5 | @Injectable() 6 | export class PublisherService { 7 | private readonly redis: Redis; 8 | private readonly logger = new Logger(PublisherService.name); 9 | constructor(private readonly redisService: RedisService) { 10 | this.redis = redisService.getOrThrow('publisher'); 11 | } 12 | 13 | async publish(channel: string, message: any) { 14 | this.logger.log(`Publishing to ${channel}: ${message}`); 15 | await this.redis.publish(channel, JSON.stringify(message)); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /client/src/components/Sidebar/OverviewButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@headlessui/react"; 2 | import { ReactElement } from "react"; 3 | 4 | type ButtonProps = { 5 | text: string; 6 | onclick: () => void; 7 | active: boolean; 8 | children: JSX.Element; 9 | }; 10 | 11 | export default function OverviewButton({ text, onclick, active, children }: ButtonProps) { 12 | return ( 13 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/auth/auth.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AuthController } from './auth.controller'; 3 | import { AuthService } from './auth.service'; 4 | 5 | describe('AuthController', () => { 6 | let controller: AuthController; 7 | 8 | beforeEach(async () => { 9 | const module: TestingModule = await Test.createTestingModule({ 10 | controllers: [AuthController], 11 | providers: [AuthService], 12 | }).compile(); 13 | 14 | controller = module.get(AuthController); 15 | }); 16 | 17 | it('should be defined', () => { 18 | expect(controller).toBeDefined(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/user/user.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { UserController } from './user.controller'; 3 | import { UserService } from './user.service'; 4 | 5 | describe('UserController', () => { 6 | let controller: UserController; 7 | 8 | beforeEach(async () => { 9 | const module: TestingModule = await Test.createTestingModule({ 10 | controllers: [UserController], 11 | providers: [UserService], 12 | }).compile(); 13 | 14 | controller = module.get(UserController); 15 | }); 16 | 17 | it('should be defined', () => { 18 | expect(controller).toBeDefined(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /BE/apps/socket-server/src/modules/map/dto/node/update.node.dto.ts: -------------------------------------------------------------------------------- 1 | import { Expose, Type } from 'class-transformer'; 2 | import { IsArray, IsNumber, IsObject, IsString, ValidateNested } from 'class-validator'; 3 | import { LocationDto } from '..'; 4 | 5 | export class NodeDto { 6 | @Expose() 7 | @IsNumber() 8 | id: number; 9 | 10 | @Expose() 11 | @IsString() 12 | keyword: string; 13 | 14 | @Expose() 15 | @IsNumber() 16 | depth: number; 17 | 18 | @Expose() 19 | @IsObject() 20 | @ValidateNested() 21 | @Type(() => LocationDto) 22 | location: LocationDto; 23 | 24 | @Expose() 25 | @IsArray() 26 | @IsNumber({}, { each: true }) 27 | children: number[]; 28 | } 29 | -------------------------------------------------------------------------------- /client/src/api/mindmap.api.ts: -------------------------------------------------------------------------------- 1 | import { instance } from "@/api"; 2 | import { MindMap } from "@/types/mindmap"; 3 | 4 | export function createMindmap() { 5 | return instance.post("/connection"); 6 | } 7 | 8 | export function getMindMap(mindMapId: string): Promise { 9 | return instance.get(`/connection/?type=mindmap&id=${mindMapId}`); 10 | } 11 | 12 | export function deleteMindMap(mindMapId: string) { 13 | return instance.delete(`/mindmap/${mindMapId}`); 14 | } 15 | 16 | export async function getMindMapByConnectionId(connectionId: string): Promise { 17 | const { data } = await instance.get(`/connection/?type=connection&id=${connectionId}`); 18 | return data.mindmapId; 19 | } 20 | -------------------------------------------------------------------------------- /BE/libs/entity/src/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; 2 | import { UserMindmapRole } from './user.mindmap.role'; 3 | 4 | @Entity('user') 5 | export class User { 6 | @PrimaryGeneratedColumn() 7 | id: number; 8 | 9 | @Column() 10 | name: string; 11 | 12 | @Column() 13 | email: string; 14 | 15 | @Column() 16 | type: string; 17 | 18 | @CreateDateColumn({ type: 'timestamp', name: 'create_date' }) 19 | createDate: Date; 20 | 21 | @OneToMany(() => UserMindmapRole, (userMindmapRole) => userMindmapRole.user, { 22 | cascade: true, 23 | onDelete: 'CASCADE', 24 | }) 25 | userMindmapRoles: UserMindmapRole[]; 26 | } 27 | -------------------------------------------------------------------------------- /BE/apps/socket-server/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('SocketServerController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()).get('/').expect(200).expect('Hello World!'); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /client/src/hooks/useAuth.ts: -------------------------------------------------------------------------------- 1 | import { getUser } from "@/api/auth.api"; 2 | import { useConnectionStore } from "@/store/useConnectionStore"; 3 | import { useQuery } from "@tanstack/react-query"; 4 | import { useEffect } from "react"; 5 | import { useNavigate } from "react-router-dom"; 6 | 7 | export default function useAuth() { 8 | const isAuthenticated = useConnectionStore((state) => state.token); 9 | const navigate = useNavigate(); 10 | 11 | const { isLoading, isError } = useQuery({ 12 | queryKey: ["user"], 13 | queryFn: getUser, 14 | enabled: !!isAuthenticated, 15 | }); 16 | 17 | useEffect(() => { 18 | if (isError) { 19 | navigate("/error"); 20 | } 21 | }, [isError]); 22 | return { isLoading }; 23 | } 24 | -------------------------------------------------------------------------------- /BE/libs/config/src/typeorm.config.ts: -------------------------------------------------------------------------------- 1 | import { TypeOrmModuleOptions } from '@nestjs/typeorm'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { entities } from '@app/entity'; 4 | 5 | export const getTypeOrmConfig = (configService: ConfigService): TypeOrmModuleOptions => ({ 6 | type: 'mysql', 7 | host: configService.get('MYSQL_HOST'), 8 | port: configService.get('MYSQL_PORT'), 9 | username: configService.get('MYSQL_USERNAME'), 10 | password: configService.get('MYSQL_PASSWORD'), 11 | database: configService.get('MYSQL_DATABASE'), 12 | entities: entities, 13 | timezone: '+09:00', 14 | synchronize: true, 15 | extra: { 16 | charset: 'utf8mb4_unicode_ci', 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/dashboard/dashboard.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { DashboardController } from './dashboard.controller'; 3 | import { DashboardService } from './dashboard.service'; 4 | 5 | describe('DashboardController', () => { 6 | let controller: DashboardController; 7 | 8 | beforeEach(async () => { 9 | const module: TestingModule = await Test.createTestingModule({ 10 | controllers: [DashboardController], 11 | providers: [DashboardService], 12 | }).compile(); 13 | 14 | controller = module.get(DashboardController); 15 | }); 16 | 17 | it('should be defined', () => { 18 | expect(controller).toBeDefined(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /client/src/pages/Mindmap/index.tsx: -------------------------------------------------------------------------------- 1 | import Spinner from "@/components/common/Spinner"; 2 | import useAuth from "@/hooks/useAuth"; 3 | import NodeListProvider from "@/store/NodeListProvider"; 4 | import { lazy, Suspense } from "react"; 5 | 6 | const MindMapMainSection = lazy(() => import("@/components/MindMapMainSection")); 7 | 8 | export default function MindMap() { 9 | const { isLoading } = useAuth(); 10 | if (isLoading) return ; 11 | return ( 12 | <> 13 |
14 | 15 | }> 16 | 17 | 18 | 19 |
20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/dashboard/dashboard.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Logger, UseGuards } from '@nestjs/common'; 2 | import { DashboardService } from './dashboard.service'; 3 | import { AuthGuard } from '@nestjs/passport'; 4 | import { User } from '../../decorators'; 5 | 6 | @Controller('dashboard') 7 | @UseGuards(AuthGuard('jwt')) 8 | export class DashboardController { 9 | private readonly logger = new Logger(DashboardController.name); 10 | constructor(private readonly dashboardService: DashboardService) {} 11 | 12 | @Get() 13 | async getDashboard(@User() user) { 14 | this.logger.log('대시보드 조회 요청 수신'); 15 | const responseData = await this.dashboardService.findAll(user.id); 16 | return responseData; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /BE/apps/api-server/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 | -------------------------------------------------------------------------------- /BE/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /BE/libs/config/src/redis.config.ts: -------------------------------------------------------------------------------- 1 | import { RedisModuleOptions } from '@liaoliaots/nestjs-redis'; 2 | import { ConfigService } from '@nestjs/config'; 3 | 4 | export const getRedisConfig = (configService: ConfigService): RedisModuleOptions => ({ 5 | config: [ 6 | { 7 | namespace: 'subscriber', 8 | host: configService.get('REDIS_HOST'), 9 | port: configService.get('REDIS_PORT'), 10 | }, 11 | { 12 | namespace: 'publisher', 13 | host: configService.get('REDIS_HOST'), 14 | port: configService.get('REDIS_PORT'), 15 | }, 16 | { 17 | namespace: 'general', 18 | host: configService.get('REDIS_HOST'), 19 | port: configService.get('REDIS_PORT'), 20 | }, 21 | ], 22 | }); 23 | -------------------------------------------------------------------------------- /client/src/hooks/useToast.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useConnectionStore } from "@/store/useConnectionStore"; 3 | 4 | export default function useToast() { 5 | const nodeError = useConnectionStore((state) => state.nodeError); 6 | const [toasts, setToasts] = useState([]); 7 | 8 | useEffect(() => { 9 | if (nodeError.length > 0) { 10 | const num = `${new Date().getTime()}-${Math.random().toString(36)}`; 11 | setToasts((prevToasts) => [ 12 | ...prevToasts, 13 | { 14 | id: num, 15 | message: nodeError[nodeError.length - 1].message, 16 | status: nodeError[nodeError.length - 1].status, 17 | }, 18 | ]); 19 | } 20 | }, [nodeError]); 21 | 22 | return { toasts, setToasts }; 23 | } 24 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/common/constant.ts: -------------------------------------------------------------------------------- 1 | export const MAX_FILE_SIZE = 1024 * 1024 * 100; // 100MB 2 | export const ALLOW_AUDIO_FILE_FORMAT = ['.m4a', '.ogg', '.ac3', '.aac', '.mp3']; 3 | export const OPENAI_PROMPT = `- 당신은 텍스트 요약 어시스턴트입니다. 4 | - 주어진 텍스트를 분석하고 핵심 단어들을 추출해 대분류, 중분류, 소분류로 나눠주세요. 5 | - 각 하위 분류는 상위 분류에 연관되는 키워드여야 합니다. 6 | - 반드시 대분류는 한개여야 합니다. 7 | - 각 객체에는 핵심 단어를 나타내는 keyword와 자식요소를 나타내는 children이 있으며, children의 경우 객체들을 포함한 배열입니다. 8 | - children 배열에는 개별 요소를 나타내는 객체가 들어갑니다. 9 | - 개별 요소는 keyword (문자열), children (배열)을 가집니다. 10 | - 마지막 자식 요소 또한 children을 필수적으로 빈 배열을 가지고 있습니다. 11 | - keyword 는 짧고 간결하게 해주세요. 12 | - keyword의 갯수는 최대 60개로 제한을 둡니다. 13 | - children의 배열의 최대 길이는 15로 제한을 둡니다. 14 | - tree 구조의 최대 depth는 4입니다. 15 | - 불필요한 띄어쓰기와 줄바꿈 문자는 제거합니다. 16 | - \`\`\` json \`\`\` 은 빼고 결과를 출력합니다.`; 17 | -------------------------------------------------------------------------------- /client/src/components/Authorize/index.tsx: -------------------------------------------------------------------------------- 1 | import { useTokenRefresh } from "@/hooks/useTokenRefresh"; 2 | import { useConnectionStore } from "@/store/useConnectionStore"; 3 | import { useEffect } from "react"; 4 | import { useNavigate } from "react-router-dom"; 5 | 6 | export default function Authorize() { 7 | const navigate = useNavigate(); 8 | const updateToken = useConnectionStore((state) => state.tokenRefresh); 9 | const refresh = useTokenRefresh(); 10 | 11 | useEffect(() => { 12 | refresh.mutate(undefined, { 13 | onSuccess: (response) => { 14 | updateToken(response.accessToken); 15 | navigate("/"); 16 | }, 17 | onError: () => navigate("/error"), 18 | }); 19 | }, [navigate]); 20 | 21 | return

Authenticating

; 22 | } 23 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/pipes/audio.file.validation.pipe.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common'; 2 | import { ALLOW_AUDIO_FILE_FORMAT } from 'apps/api-server/src/common/constant'; 3 | import { extname } from 'path'; 4 | 5 | @Injectable() 6 | export class AudioFileValidationPipe implements PipeTransform { 7 | transform(file: Express.Multer.File) { 8 | if (!file) { 9 | throw new BadRequestException('No file uploaded'); 10 | } 11 | 12 | const ext = extname(file.originalname).toLowerCase(); 13 | console.log(ext); 14 | 15 | if (!ALLOW_AUDIO_FILE_FORMAT.includes(ext)) { 16 | throw new BadRequestException(`File type not allowed. Only ${ALLOW_AUDIO_FILE_FORMAT.join(', ')} allowed`); 17 | } 18 | 19 | return file; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /client/src/components/common/Error.tsx: -------------------------------------------------------------------------------- 1 | import errorIcon from "@/assets/error"; 2 | import { Button } from "@headlessui/react"; 3 | export default function Error() { 4 | return ( 5 |
6 | error 7 |
8 |

OOPS!

9 |

무언가 잘못됐어요

10 | 16 |
17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /client/src/components/common/Modal.tsx: -------------------------------------------------------------------------------- 1 | export default function Modal({ open, closeModal, children, optionalStyles = "" }) { 2 | if (!open) return null; 3 | 4 | return ( 5 |
6 |
e.stopPropagation()} 9 | > 10 | {children} 11 | 17 |
18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /client/src/konva_mindmap/utils/findParentNodeKey.ts: -------------------------------------------------------------------------------- 1 | import { NodeData } from "@/types/Node"; 2 | 3 | export function findParentNodeKey(nodeId: number, data: NodeData) { 4 | for (const id in data) { 5 | const node = data[parseInt(id)]; 6 | if (node.children.includes(nodeId)) { 7 | return node.id; 8 | } 9 | } 10 | return null; 11 | } 12 | 13 | export function getParentNodeKeys(nodeId: number, data: NodeData) { 14 | const parentNodes: number[] = []; 15 | let currentNodeId: number | null = nodeId; 16 | 17 | while (currentNodeId !== null) { 18 | const parentNodeId = findParentNodeKey(currentNodeId, data); 19 | if (parentNodeId !== null) { 20 | parentNodes.push(parentNodeId); 21 | currentNodeId = parentNodeId; 22 | } else { 23 | break; 24 | } 25 | } 26 | 27 | return parentNodes; 28 | } 29 | -------------------------------------------------------------------------------- /client/src/konva_mindmap/utils/vector.ts: -------------------------------------------------------------------------------- 1 | import { Location } from "@/konva_mindmap/types/location"; 2 | 3 | export function calculateVector( 4 | rootNodeLocation: Location, 5 | parentNodeLocation: Location, 6 | angleDegrees: number, 7 | magnitude = 1, 8 | ) { 9 | const dx = parentNodeLocation.x - rootNodeLocation.x; 10 | const dy = parentNodeLocation.y - rootNodeLocation.y; 11 | 12 | const length = Math.sqrt(dx ** 2 + dy ** 2); 13 | const unitX = dx / length; 14 | const unitY = dy / length; 15 | 16 | const angleRadians = (angleDegrees * Math.PI) / 180; 17 | const rotatedX = unitX * Math.cos(angleRadians) - unitY * Math.sin(angleRadians); 18 | const rotatedY = unitX * Math.sin(angleRadians) + unitY * Math.cos(angleRadians); 19 | 20 | return { 21 | x: rotatedX * magnitude, 22 | y: rotatedY * magnitude, 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /BE/libs/entity/src/user.mindmap.role.ts: -------------------------------------------------------------------------------- 1 | import { Column, DeleteDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; 2 | import { Role } from './enum/role.enum'; 3 | import { User } from './user.entity'; 4 | import { Mindmap } from './mindmap.entity'; 5 | 6 | @Entity('user_mindmap_role') 7 | export class UserMindmapRole { 8 | @PrimaryGeneratedColumn() 9 | id: number; 10 | 11 | @ManyToOne(() => User, (user) => user.userMindmapRoles, { nullable: false }) 12 | @JoinColumn({ name: 'user_id' }) 13 | user: User; 14 | 15 | @ManyToOne(() => Mindmap, (mindmap) => mindmap.userMindmapRoles, { nullable: false }) 16 | @JoinColumn({ name: 'mindmap_id' }) 17 | mindmap: Mindmap; 18 | 19 | @Column({ type: 'enum', enum: Role, default: Role.OWNER }) 20 | role: Role; 21 | 22 | @DeleteDateColumn() 23 | deletedAt: Date | null; 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/fe-ci.yaml: -------------------------------------------------------------------------------- 1 | name: Frontend CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - dev 7 | paths: 8 | - "client/**" 9 | 10 | jobs: 11 | fe-test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v3 16 | 17 | - name: Set up Node.js 18 | uses: actions/setup-node@v2 19 | with: 20 | node-version: "22" 21 | 22 | - name: Install required libraries 23 | run: | 24 | sudo apt-get update 25 | sudo apt-get install -y pkg-config libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev libpng-dev 26 | 27 | - name: Install frontend dependencies 28 | working-directory: client 29 | run: npm install 30 | 31 | - name: Run frontend tests 32 | working-directory: client 33 | run: npm run test 34 | -------------------------------------------------------------------------------- /BE/apps/socket-server/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import { ConfigService } from '@nestjs/config'; 4 | import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; 5 | import { WsExceptionFilter } from './exceptionfilter/ws.exceptionFilter'; 6 | 7 | async function bootstrap() { 8 | const app = await NestFactory.create(AppModule); 9 | const configService = app.get(ConfigService); 10 | app.useLogger(app.get(WINSTON_MODULE_NEST_PROVIDER)); 11 | 12 | app.useGlobalFilters(new WsExceptionFilter()); 13 | 14 | const port = configService.get('SOCKET_PORT'); 15 | app.enableCors({ 16 | origin: ['https://boomap.site', 'https://www.boomap.site'], 17 | methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS', 18 | credentials: true, 19 | }); 20 | await app.listen(port); 21 | } 22 | bootstrap(); 23 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/ai/dto/clova.speech.request.dtd.ts: -------------------------------------------------------------------------------- 1 | export class ClovaSpeechRequestDto { 2 | private formData: FormData; 3 | private headers = { 4 | 'X-CLOVASPEECH-API-KEY': '', 5 | 'Content-Type': 'multipart/form-data', 6 | }; 7 | private params = { 8 | completion: 'sync', 9 | diarization: { enable: false }, 10 | language: 'ko-KR', 11 | }; 12 | 13 | constructor(apiKey: string, audioFile: Express.Multer.File) { 14 | this.headers['X-CLOVASPEECH-API-KEY'] = apiKey; 15 | 16 | const blob = new Blob([audioFile.buffer], { type: audioFile.mimetype }); 17 | this.formData = new FormData(); 18 | this.formData.append('media', blob); 19 | this.formData.append('params', JSON.stringify(this.params)); 20 | } 21 | 22 | getFormData() { 23 | return this.formData; 24 | } 25 | 26 | getHeaders() { 27 | return this.headers; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yaml: -------------------------------------------------------------------------------- 1 | name: CD 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v2 14 | 15 | - name: server-ssh 16 | uses: appleboy/ssh-action@master 17 | with: 18 | host: ${{ secrets.SERVER_HOST }} 19 | username: ${{ secrets.SERVER_USERNAME }} 20 | key: ${{ secrets.SERVER_KEY }} 21 | port: ${{ secrets.SERVER_PORT }} 22 | script: | 23 | cd /root/web32-BooMap 24 | git pull origin main 25 | rm -f .env 26 | rm -f client/.env.production 27 | echo "${{ secrets.ENV_BE }}" > .env 28 | echo "${{ secrets.ENV_FE }}" > client/.env.production 29 | docker-compose down 30 | docker-compose up -d --build 31 | -------------------------------------------------------------------------------- /client/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": ["@typescript-eslint", "prettier"], 4 | "extends": ["prettier", "airbnb", "airbnb/hooks", "plugin:@typescript-eslint/recommended"], 5 | "settings": { 6 | "import/resolver": { 7 | "node": { 8 | "paths": ["src"], 9 | }, 10 | "typescript": {}, 11 | }, 12 | }, 13 | "parserOptions": { 14 | "ecmaFeatures": { 15 | "jsx": true, 16 | }, 17 | "ecmaVersion": "latest", 18 | "sourceType": "module", 19 | }, 20 | "rules": { 21 | "quotes": ["error", "double"], 22 | "react/react-in-jsx-scope": "off", 23 | "react/jsx-filename-extension": "off", 24 | "react/button-has-type": "off", 25 | "import/no-absolute-path": "off", 26 | "import/no-unresolved": "off", 27 | "import/no-extraneous-dependencies": "off", 28 | "prettier/prettier": "error", 29 | }, 30 | } 31 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/mindmap/mindmap.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Delete, Param, Put, UseGuards } from '@nestjs/common'; 2 | import { MindmapService } from './mindmap.service'; 3 | import { AuthGuard } from '@nestjs/passport'; 4 | import { User } from '../../decorators'; 5 | import { UpdateMindmapDto } from 'apps/socket-server/src/modules/map/dto'; 6 | 7 | @Controller('mindmap') 8 | @UseGuards(AuthGuard('jwt')) 9 | export class MindmapController { 10 | constructor(private readonly mindmapService: MindmapService) {} 11 | 12 | @Put(':mindmapId') 13 | update(@Param('mindmapId') mindmapId: number, @Body() updateMindmapDto: UpdateMindmapDto) { 14 | return this.mindmapService.update(mindmapId, updateMindmapDto); 15 | } 16 | 17 | @Delete(':mindmapId') 18 | delete(@Param('mindmapId') mindmapId: number, @User() user) { 19 | return this.mindmapService.delete(mindmapId, user.id); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | api-server: 3 | build: 4 | context: ./BE 5 | dockerfile: Dockerfile.api 6 | container_name: api 7 | env_file: 8 | - .env 9 | networks: 10 | - my-network 11 | 12 | nginx: 13 | build: 14 | context: . 15 | dockerfile: Dockerfile.nginx 16 | ports: 17 | - "80:80" 18 | - "443:443" 19 | container_name: nginx 20 | volumes: 21 | - /etc/letsencrypt:/etc/letsencrypt:ro 22 | networks: 23 | - my-network 24 | 25 | redis: 26 | image: redis:7.4.1-alpine 27 | container_name: redis 28 | networks: 29 | - my-network 30 | 31 | socket-server: 32 | build: 33 | context: ./BE 34 | dockerfile: Dockerfile.socket 35 | container_name: socket 36 | env_file: 37 | - .env 38 | networks: 39 | - my-network 40 | 41 | networks: 42 | my-network: 43 | driver: bridge 44 | -------------------------------------------------------------------------------- /client/src/konva_mindmap/utils/nodeAttrs.ts: -------------------------------------------------------------------------------- 1 | const NODE_DEFAULT_SIZE = 80; 2 | const NODE_DEFAULT_RATIO = 7; 3 | 4 | //NODE 5 | export const NODE_WIDTH_AND_HEIGHT = 100; 6 | export const NODE_RADIUS = (depth: number) => NODE_DEFAULT_SIZE - depth * NODE_DEFAULT_RATIO; 7 | export const TEXT_OFFSET_X = (depth: number) => NODE_DEFAULT_SIZE - depth * 10; 8 | export const TEXT_OFFSET_Y = (depth: number) => 5 * depth - NODE_DEFAULT_SIZE - 5; 9 | export const TEXT_WIDTH = (depth: number) => NODE_DEFAULT_SIZE * 2 - depth * 18; 10 | 11 | //CONNECTED_LINE 12 | export const CONNECTED_LINE_FROM = (depth: number) => NODE_DEFAULT_SIZE - depth * 7 + 10; 13 | export const CONNECTED_LINE_TO = (depth: number) => NODE_DEFAULT_SIZE - depth * 7 + 3; 14 | 15 | //TEXT 16 | export const TEXT_FONT_SIZE = 16; 17 | 18 | export const TOOL_OFFSET_X = NODE_DEFAULT_SIZE - 30; 19 | export const TOOL_OFFSET_Y = (radius: number) => NODE_DEFAULT_SIZE + radius - 20; 20 | -------------------------------------------------------------------------------- /client/src/pages/Main/index.tsx: -------------------------------------------------------------------------------- 1 | import Spinner from "@/components/common/Spinner"; 2 | import Profile from "@/components/MindMapHeader/Profile"; 3 | import useAuth from "@/hooks/useAuth"; 4 | import { lazy, Suspense } from "react"; 5 | 6 | const DashBoard = lazy(() => import("@/components/Dashboard")); 7 | 8 | export default function MainPage() { 9 | const { isLoading } = useAuth(); 10 | return ( 11 | <> 12 | {isLoading && } 13 |
14 | 15 |
16 |
17 |

대시보드

18 |
19 | }> 20 | 21 | 22 |
23 |
24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /BE/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | /build 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | pnpm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | 15 | # OS 16 | .DS_Store 17 | 18 | # Tests 19 | /coverage 20 | /.nyc_output 21 | 22 | # IDEs and editors 23 | /.idea 24 | .project 25 | .classpath 26 | .c9/ 27 | *.launch 28 | .settings/ 29 | *.sublime-workspace 30 | 31 | # IDE - VSCode 32 | .vscode/* 33 | !.vscode/settings.json 34 | !.vscode/tasks.json 35 | !.vscode/launch.json 36 | !.vscode/extensions.json 37 | 38 | # dotenv environment variable files 39 | .env 40 | .env.development.local 41 | .env.test.local 42 | .env.production.local 43 | .env.local 44 | 45 | # temp directory 46 | .temp 47 | .tmp 48 | 49 | # Runtime data 50 | pids 51 | *.pid 52 | *.seed 53 | *.pid.lock 54 | 55 | # Diagnostic reports (https://nodejs.org/api/report.html) 56 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 57 | -------------------------------------------------------------------------------- /client/src/api/ai.api.ts: -------------------------------------------------------------------------------- 1 | import { useConnectionStore } from "@/store/useConnectionStore"; 2 | import { logOnDev } from "@/utils/logging"; 3 | import axios from "axios"; 4 | import { InternalAxiosRequestConfig } from "axios"; 5 | 6 | export const instanceForAi = axios.create({ 7 | baseURL: import.meta.env.VITE_APP_API_SERVER_BASE_URL, 8 | withCredentials: true, 9 | headers: { 10 | "Content-Type": "multipart/form-data", 11 | }, 12 | }); 13 | 14 | instanceForAi.interceptors.request.use((config: InternalAxiosRequestConfig) => { 15 | const { method, baseURL, url } = config; 16 | logOnDev(`🚀 [API Request] ${method?.toUpperCase()} ${baseURL} ${url}`); 17 | const accessToken = useConnectionStore.getState().token; 18 | 19 | config.headers["Authorization"] = `Bearer ${accessToken}`; 20 | return config; 21 | }); 22 | 23 | export function AudioAiConvert(formData: FormData) { 24 | return instanceForAi.post(`/ai/audio`, formData); 25 | } 26 | -------------------------------------------------------------------------------- /client/src/hooks/useLoading.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | export type MindMapLoadingHook = { 4 | loadingStatus: MindMapLoading; 5 | updateLoadingStatus: ({ type, status }: { type: "aiPending" | "socketLoading"; status: boolean }) => void; 6 | }; 7 | 8 | type MindMapLoading = { 9 | aiPending: boolean; 10 | socketLoading: boolean; 11 | }; 12 | export default function useLoading(): MindMapLoadingHook { 13 | const [loadingStatus, setLoadingStatus] = useState({ 14 | aiPending: false, 15 | socketLoading: false, 16 | }); 17 | 18 | function updateLoadingStatus({ type, status }: { type: "aiPending" | "socketLoading"; status: boolean }) { 19 | if (type === "aiPending") setLoadingStatus((prev) => ({ ...prev, aiPending: status })); 20 | else setLoadingStatus((prev) => ({ ...prev, socketLoading: status })); 21 | } 22 | return { 23 | loadingStatus, 24 | updateLoadingStatus, 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /client/src/store/createAuthSlice.ts: -------------------------------------------------------------------------------- 1 | import { ConnectionStore } from "@/types/store"; 2 | import { StateCreator } from "zustand"; 3 | 4 | export interface AuthSlice { 5 | token: string; 6 | email: null | string; 7 | name: null | string; 8 | id: null | number; 9 | tokenRefresh: (accessToken: string) => void; 10 | logout: () => void; 11 | setUser: (email: string, name: string, id: number) => void; 12 | } 13 | 14 | export const createAuthSlice: StateCreator = (set, get) => ({ 15 | token: "", 16 | email: null, 17 | name: null, 18 | id: null, 19 | 20 | tokenRefresh: (accessToken: string) => set({ token: accessToken }), 21 | 22 | logout: () => { 23 | set({ email: null, name: null, token: "" }); 24 | get().resetOwnedMindMap(); 25 | location.href = "/"; 26 | }, 27 | 28 | setUser: (email: string, name: string, id: number) => { 29 | set({ email, name, id }); 30 | }, 31 | }); 32 | -------------------------------------------------------------------------------- /BE/apps/socket-server/src/exceptionfilter/ws.exceptionFilter.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsHost, Catch, Logger } from '@nestjs/common'; 2 | import { BaseWsExceptionFilter } from '@nestjs/websockets'; 3 | 4 | @Catch() 5 | export class WsExceptionFilter extends BaseWsExceptionFilter { 6 | private readonly logger = new Logger('WsExceptionFilter'); 7 | 8 | catch(exception: any, host: ArgumentsHost): void { 9 | const client = host.switchToWs().getClient(); 10 | const error = exception.getError?.() || exception.message || exception; 11 | 12 | const errorResponse = { 13 | status: 'error', 14 | message: typeof error === 'object' ? JSON.stringify(error) : error, 15 | timestamp: new Date().toISOString(), 16 | }; 17 | 18 | this.logger.error(`WebSocket Error: ${JSON.stringify(errorResponse)}`); 19 | this.logger.error(`Stack: ${exception.stack}`); 20 | 21 | client.emit('error', errorResponse); 22 | 23 | return; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /client/src/store/createRoleSlice.ts: -------------------------------------------------------------------------------- 1 | import { ConnectionStore } from "@/types/store"; 2 | import { StateCreator } from "zustand"; 3 | 4 | export interface RoleSlice { 5 | currentRole: "owner" | "editor"; 6 | updateRole: (role: "owner" | "editor") => void; 7 | checkRole: (connectionId: string) => void; 8 | } 9 | 10 | export const createRoleSlice: StateCreator = (set, get) => ({ 11 | currentRole: "editor", 12 | 13 | updateRole: (role: "owner" | "editor") => set({ currentRole: role }), 14 | 15 | checkRole: (connectionId: string) => { 16 | //회원 마인드맵 중 확인 17 | if (get().ownedMindMap.some((ids) => ids === connectionId)) { 18 | get().updateRole("owner"); 19 | return; 20 | } 21 | //비회원 마인드맵 중 확인 22 | if (get().ownedMindMapForGuest.some((ids) => ids === connectionId)) { 23 | get().updateRole("owner"); 24 | return; 25 | } 26 | get().updateRole("editor"); 27 | }, 28 | }); 29 | -------------------------------------------------------------------------------- /client/src/components/MindMapCanvas/NoNodeInform.tsx: -------------------------------------------------------------------------------- 1 | import { addNode } from "@/konva_mindmap/events/addNode"; 2 | import { useNodeListContext } from "@/store/NodeListProvider"; 3 | import { Button } from "@headlessui/react"; 4 | 5 | export default function NoNodeInform() { 6 | const { data, selectedNode, overrideNodeData, selectNode } = useNodeListContext(); 7 | function initializeRootNode() { 8 | addNode(data, selectedNode, overrideNodeData, (newNodeId) => { 9 | selectNode({ nodeId: newNodeId, parentNodeId: null }); 10 | }); 11 | } 12 | return ( 13 |
14 |

브레인스토밍을 시작해보세요.

15 | 18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /client/src/types/NodePayload.ts: -------------------------------------------------------------------------------- 1 | import { NodeData } from "./Node"; 2 | 3 | export type actionType = 4 | | "createNode" 5 | | "deleteNode" 6 | | "updateNode" 7 | | "updateTitle" 8 | | "updateContent" 9 | | "aiRequest" 10 | | "audioAiRequest"; 11 | 12 | export type HandleSocketEventPayloads = 13 | | createNodePayload 14 | | deleteNodePayload 15 | | updateNodePayload 16 | | updateContentPayload 17 | | updateTitlePayload 18 | | aiRequestPayload; 19 | 20 | type createNodePayload = { 21 | id: number; 22 | keyword: string; 23 | depth: number; 24 | location: { x: number; y: number }; 25 | children: number[] | []; 26 | newNode: boolean; 27 | parentId: number | null; 28 | }; 29 | 30 | type deleteNodePayload = { 31 | id: string[]; 32 | }; 33 | 34 | type updateNodePayload = NodeData; 35 | 36 | type updateTitlePayload = { title: string }; 37 | type updateContentPayload = { content: string }; 38 | type aiRequestPayload = { aiContent: string }; 39 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", // 최신 JavaScript로 컴파일 4 | "module": "esnext", // ES 모듈 사용 5 | "moduleResolution": "node", // Node.js 모듈 해석 방식 6 | "jsx": "react-jsx", // React JSX 사용 7 | "esModuleInterop": true, // CommonJS와 ES6 모듈 호환성 8 | "skipLibCheck": true, // 라이브러리 타입 검사 건너뛰기 9 | "forceConsistentCasingInFileNames": true, 10 | "baseUrl": ".", // 경로 설정 11 | "paths": { 12 | "@/*": ["src/*"] 13 | }, 14 | "declaration": false, // .d.ts 파일 생성을 방지 15 | "outDir": "./dist", // 번들된 파일을 dist 폴더에 출력 16 | "noEmit": false, // emit을 true로 설정해야 실제 파일이 생성됨 17 | "isolatedModules": true, // 모듈로 분리된 파일을 처리 18 | "skipDefaultLibCheck": true, // 기본 라이브러리 파일 체크 건너뛰기 19 | "resolveJsonModule": true 20 | }, 21 | "include": [ 22 | "src/**/*", // 모든 src 파일 포함 23 | "postcss.config.js" // 설정 파일 추가 24 | ], 25 | "exclude": [ 26 | "node_modules" // node_modules 제외 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /client/src/components/MindMapMainSection/ControlSection/index.tsx: -------------------------------------------------------------------------------- 1 | import ListView from "@/components/MindMapMainSection/ControlSection/ListView"; 2 | import TextUpload from "@/components/MindMapMainSection/ControlSection/TextUpload"; 3 | import VoiceFileUpload from "@/components/MindMapMainSection/ControlSection/VoiceFileUpload"; 4 | import useSection from "@/hooks/useSection"; 5 | 6 | export default function ControlSection() { 7 | const mode = useSection().getmode() as keyof typeof modeView; 8 | const modeView = { 9 | voiceupload: , 10 | listview: , 11 | textupload: , 12 | }; 13 | 14 | return ( 15 |
16 |
17 | {modeView[mode] || modeView.listview} 18 |
19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /client/src/components/Modal/ConfirmUploadModal.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@headlessui/react"; 2 | import Modal from "../common/Modal"; 3 | 4 | export default function ConfirmUploadModal({ open, closeModal, onConfirm }) { 5 | return ( 6 | 7 |

8 | AI 기능을 사용하면 9 |
10 | 마인드맵이 초기화됩니다 11 |

12 |
13 | 19 | 25 |
26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /client/src/components/Sidebar/ToggleButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@headlessui/react"; 2 | import { FaAnglesLeft } from "react-icons/fa6"; 3 | 4 | export default function ToggleButton({ isSidebarOpen, toggleSidebar }) { 5 | const animateOptions = { 6 | button: isSidebarOpen ? "left-64" : "left-0", 7 | arrow: isSidebarOpen ? "rotate-0" : "rotate-180", 8 | }; 9 | const transitionClasses = { 10 | button: "transition-all duration-300", 11 | arrow: "transition-transform duration-300", 12 | }; 13 | 14 | return ( 15 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /client/src/components/common/Forbidden.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@headlessui/react"; 2 | import lockIcon from "@/assets/lock.webp"; 3 | 4 | export default function Forbidden() { 5 | return ( 6 |
7 |
8 |

403

9 |

접근이 거부되었습니다.

10 |

요청하신 페이지에 대한 접근이 거부되었습니다.

11 |

입력한 주소가 정확한지 다시 한 번 확인해 주세요.

12 | 18 |
19 | forbidden 20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/middlewares/logger.middleware.ts: -------------------------------------------------------------------------------- 1 | import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; 2 | import { Inject, Injectable, Logger, NestMiddleware } from '@nestjs/common'; 3 | import { NextFunction, Request, Response } from 'express'; 4 | 5 | @Injectable() 6 | export class LoggerMiddleware implements NestMiddleware { 7 | constructor(@Inject(WINSTON_MODULE_NEST_PROVIDER) private readonly logger: Logger) {} 8 | 9 | use(req: Request, res: Response, next: NextFunction) { 10 | const { ip, method, originalUrl } = req; 11 | const userAgent = req.get('user-agent'); 12 | // const payload = headers.authorization ? this.jwt.decode(headers.authorization) : null; 13 | const userId = 'guest'; 14 | const datetime = new Date(); 15 | res.on('finish', () => { 16 | const { statusCode } = res; 17 | this.logger.log(`${datetime} USER-${userId} ${method} ${originalUrl} ${statusCode} ${ip} ${userAgent}`); 18 | }); 19 | 20 | next(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/connection/connection.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ConnectionController } from './connection.controller'; 3 | import { ConnectionService } from './connection.service'; 4 | 5 | describe('ConnectionController', () => { 6 | let controller: ConnectionController; 7 | let connectionService: jest.Mocked; 8 | 9 | beforeEach(async () => { 10 | connectionService = { 11 | createMindmapId: jest.fn(), 12 | } as any; 13 | 14 | const module: TestingModule = await Test.createTestingModule({ 15 | controllers: [ConnectionController], 16 | providers: [ 17 | { 18 | provide: ConnectionService, 19 | useValue: connectionService, 20 | }, 21 | ], 22 | }).compile(); 23 | 24 | controller = module.get(ConnectionController); 25 | }); 26 | 27 | it('should be defined', () => { 28 | expect(controller).toBeDefined(); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /client/src/components/common/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@headlessui/react"; 2 | import notFoundIcon from "@/assets/notFound.webp"; 3 | 4 | export default function NotFound() { 5 | return ( 6 |
7 | notfound 8 |
9 |

404

10 |

페이지를 찾을 수 없습니다.

11 |

페이지가 존재하지 않거나, 사용할 수 없는 페이지입니다.

12 |

입력한 주소가 정확한지 다시 한 번 확인해 주세요.

13 | 19 |
20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /client/src/konva_mindmap/events/ratioSizing.ts: -------------------------------------------------------------------------------- 1 | export function ratioSizing(e, dimensions, setDimensions) { 2 | e.evt.preventDefault(); 3 | 4 | const scaleBy = 1.02; 5 | const stage = e.target.getStage(); 6 | const oldScale = stage.attrs.scaleX; 7 | const pointer = stage.getPointerPosition(); 8 | const mousePointTo = { 9 | // 포인터가 가르키는 x,y좌표를 기존 확대비율로 나눈 것에 stage의 x좌표에 기존 확대비율로 나눈 값을 뻄 10 | x: pointer.x / oldScale - stage.x() / oldScale, 11 | y: pointer.y / oldScale - stage.y() / oldScale, 12 | }; 13 | 14 | // 새로운 비율을 deltaY가 0보다 작게 되면 기존 값에 1.02만큼 곱한 값을, 아니면 기존 값에 1.02만큼 나눈 값으로 설정 15 | // deltaY는 사용자가 휠을 스크롤한 양임 16 | // 위로 스크롤하면 양수, 아래로 스크롤하면 음수 17 | const newScale = e.evt.deltaY < 0 ? oldScale * scaleBy : oldScale / scaleBy; 18 | const newPos = { 19 | x: pointer.x - mousePointTo.x * newScale, 20 | y: pointer.y - mousePointTo.y * newScale, 21 | }; 22 | 23 | setDimensions({ 24 | ...dimensions, 25 | scale: newScale, 26 | x: newPos.x, 27 | y: newPos.y, 28 | }); 29 | } 30 | -------------------------------------------------------------------------------- /client/src/components/Modal/DeleteMindMapModal.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@headlessui/react"; 2 | import Modal from "../common/Modal"; 3 | 4 | export default function DeleteMindMapModal({ open, closeModal, confirmDelete, data }) { 5 | return ( 6 | 7 |

8 | {data.title} 9 |

마인드 맵을 삭제하시겠습니까? 10 |

11 |
12 | 18 | 24 |
25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /client/src/components/MindMapMainSection/ControlSection/ListView/NodeList.tsx: -------------------------------------------------------------------------------- 1 | import NodeItem from "@/components/MindMapMainSection/ControlSection/ListView/NodeItem"; 2 | import useAccordion from "@/hooks/useAccordion"; 3 | import { Node, NodeData } from "@/types/Node"; 4 | 5 | type NodeListProps = { 6 | data: NodeData; 7 | parentNodeId?: number; 8 | root: Node; 9 | }; 10 | 11 | export default function NodeList({ data, parentNodeId, root }: NodeListProps) { 12 | const { open, handleAccordion, openAccordion } = useAccordion(); 13 | 14 | return ( 15 |
16 | 23 | {open && 24 | root.children.map((childId) => { 25 | return ; 26 | })} 27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import { ConfigService } from '@nestjs/config'; 4 | import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; 5 | import * as cookieParser from 'cookie-parser'; 6 | import { ValidationPipe } from '@nestjs/common'; 7 | 8 | async function bootstrap() { 9 | const app = await NestFactory.create(AppModule); 10 | const configService = app.get(ConfigService); 11 | app.use(cookieParser()); 12 | app.useLogger(app.get(WINSTON_MODULE_NEST_PROVIDER)); 13 | app.setGlobalPrefix('api'); 14 | app.useGlobalPipes( 15 | new ValidationPipe({ 16 | transform: true, 17 | whitelist: true, 18 | }), 19 | ); 20 | app.enableCors({ 21 | origin: ['https://boomap.site', 'https://www.boomap.site'], 22 | methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS', 23 | credentials: true, 24 | }); 25 | 26 | const port = configService.get('API_PORT'); 27 | await app.listen(port); 28 | } 29 | 30 | bootstrap(); 31 | -------------------------------------------------------------------------------- /client/src/components/Modal/ConfirmResetModal.tsx: -------------------------------------------------------------------------------- 1 | import Modal from "@/components/common/Modal"; 2 | import { Button } from "@headlessui/react"; 3 | 4 | type DeleteConfirmModalProps = { 5 | open: boolean; 6 | closeModal: () => void; 7 | onConfirm: () => void; 8 | }; 9 | export default function ConfirmResetModal({ open, closeModal, onConfirm }: DeleteConfirmModalProps) { 10 | return ( 11 | 12 |

모든 노드를 초기화할까요?

13 |
14 | 20 | 26 |
27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /client/src/konva_mindmap/utils/collision.ts: -------------------------------------------------------------------------------- 1 | import Konva from "konva"; 2 | import { Shape } from "konva/lib/Shape"; 3 | 4 | export function isCollided(r1: Konva.RectConfig, r2: Konva.RectConfig) { 5 | return !(r2.x > r1.x + r1.width || r2.x + r2.width < r1.x || r2.y > r1.y + r1.height || r2.y + r2.height < r1.y); 6 | } 7 | 8 | export function moveOnCollision( 9 | targetNode: Konva.Group | Shape, 10 | draggedNode: Konva.Group | Shape, 11 | ) { 12 | const dx = targetNode.attrs.x - draggedNode.attrs.x; 13 | const dy = targetNode.attrs.y - draggedNode.attrs.y; 14 | if (Math.sqrt(dx * dx + dy * dy) === 0) { 15 | return { 16 | x: targetNode.attrs.x + Math.random() * 20, 17 | y: targetNode.attrs.y, 18 | }; 19 | } 20 | 21 | const angle = Math.atan2(dy, dx); 22 | 23 | const minDistance = 10; 24 | 25 | const moveX = Math.cos(angle) * minDistance; 26 | const moveY = Math.sin(angle) * minDistance; 27 | 28 | return { 29 | x: targetNode.attrs.x + moveX, 30 | y: targetNode.attrs.y + moveY, 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | BooMap 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /client/src/components/Modal/LatestMindMapModal.tsx: -------------------------------------------------------------------------------- 1 | import Modal from "@/components/common/Modal"; 2 | import { Button } from "@headlessui/react"; 3 | 4 | export default function LatestMindMapModal({ open, closeModal, navigateToLatestMindap, navigateToNewMindMap }) { 5 | return ( 6 | 7 |
8 |

최근에 작업했던 마인드맵이 있어요!

9 |
10 | 16 | 22 |
23 |
24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /client/src/components/MindMapMainSection/MindMapView.tsx: -------------------------------------------------------------------------------- 1 | import MindMapCanvas from "@/components/MindMapCanvas"; 2 | import ControlSection from "@/components/MindMapMainSection/ControlSection"; 3 | import Minutes from "@/components/Minutes"; 4 | import useMinutes from "@/hooks/useMinutes"; 5 | import Spinner from "../common/Spinner"; 6 | import { useNodeListContext } from "@/store/NodeListProvider"; 7 | import { createPortal } from "react-dom"; 8 | import AiSpinner from "@/components/common/aiSpinner"; 9 | 10 | export default function MindMapView() { 11 | const { showMinutes, handleShowMinutes, isAnimating, handleIsAnimating } = useMinutes(); 12 | const { loadingStatus } = useNodeListContext(); 13 | 14 | return ( 15 | <> 16 | {loadingStatus.socketLoading && createPortal(, document.body)} 17 | {loadingStatus.aiPending && createPortal(, document.body)} 18 | 19 | 20 | 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/strategies/kakao.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from '@nestjs/config'; 2 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 3 | import { PassportStrategy } from '@nestjs/passport'; 4 | import { Strategy } from 'passport-kakao'; 5 | 6 | @Injectable() 7 | export class KakaoStrategy extends PassportStrategy(Strategy, 'kakao') { 8 | constructor(private readonly configService: ConfigService) { 9 | super({ 10 | clientID: configService.get('KAKAO_CLIENT_ID'), 11 | clientSecret: configService.get('KAKAO_SECRET'), 12 | callbackURL: configService.get('KAKAO_CALLBACK_URL'), 13 | }); 14 | } 15 | 16 | async validate(accessToken: string, refreshToken: string, profile: any, done) { 17 | try { 18 | const { _json } = profile; 19 | const email = _json && _json.kakao_account.email; 20 | if (!email) { 21 | throw new UnauthorizedException('이메일은 필수입니다.'); 22 | } 23 | const name = _json && _json.properties.nickname; 24 | return { name, email }; 25 | } catch (error) { 26 | return done(error, false); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/connection/connection.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ConnectionService } from './connection.service'; 3 | import { RedisService } from '@liaoliaots/nestjs-redis'; 4 | import { Redis } from 'ioredis'; 5 | 6 | describe('ConnectionService', () => { 7 | let service: ConnectionService; 8 | let redisService: jest.Mocked; 9 | let redisMock: jest.Mocked; 10 | 11 | beforeEach(async () => { 12 | redisMock = { 13 | sadd: jest.fn(), 14 | srem: jest.fn(), 15 | sismember: jest.fn(), 16 | } as any; 17 | 18 | redisService = { 19 | getOrThrow: jest.fn().mockReturnValue(redisMock), 20 | } as any; 21 | 22 | const module: TestingModule = await Test.createTestingModule({ 23 | providers: [ 24 | ConnectionService, 25 | { 26 | provide: RedisService, 27 | useValue: redisService, 28 | }, 29 | ], 30 | }).compile(); 31 | 32 | service = module.get(ConnectionService); 33 | }); 34 | 35 | it('should be defined', () => { 36 | expect(service).toBeDefined(); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /client/src/components/Dashboard/NoMindMap.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@headlessui/react"; 2 | import { FaPlus } from "react-icons/fa"; 3 | import { useNavigate } from "react-router-dom"; 4 | import cloud from "@/assets/dashbordIcon.png"; 5 | import { useConnectionStore } from "@/store/useConnectionStore"; 6 | 7 | export default function NoMindMap() { 8 | const createConnection = useConnectionStore((state) => state.createConnection); 9 | const navigate = useNavigate(); 10 | return ( 11 |
12 |

13 | 현재 만들어둔 마인드맵이 없어요 14 |
15 | 새로운 마인드맵을 생성하고 브레인스토밍 해보세요! 16 |

17 | 로그인 후 마인드맵 없을 때 아이콘 18 | 24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/strategies/github.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { PassportStrategy } from '@nestjs/passport'; 4 | import { Strategy } from 'passport-github2'; 5 | 6 | @Injectable() 7 | export class GithubStrategy extends PassportStrategy(Strategy, 'github') { 8 | constructor(private readonly configService: ConfigService) { 9 | super({ 10 | clientID: configService.get('GITHUB_CLIENT_ID'), 11 | clientSecret: configService.get('GITHUB_CLIENT_SECRET'), 12 | callbackURL: configService.get('GITHUB_CALLBACK_URL'), 13 | scope: ['user:email'], 14 | }); 15 | } 16 | 17 | async validate(accessToken: string, refreshToken: string, profile: any, done) { 18 | try { 19 | const { emails, displayName } = profile; 20 | if (!emails || !emails.length) { 21 | throw new UnauthorizedException('이메일은 필수입니다.'); 22 | } 23 | const name = displayName; 24 | const email = emails[0].value; 25 | 26 | return { name, email }; 27 | } catch (error) { 28 | return done(error, false); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/exceptions/exception.ts: -------------------------------------------------------------------------------- 1 | import { HttpException } from '@nestjs/common'; 2 | 3 | export class authException extends HttpException { 4 | constructor(message: string = 'Unauthorized', statusCode: number = 401) { 5 | super(message, statusCode); 6 | } 7 | } 8 | 9 | export class ConnectionException extends HttpException { 10 | constructor(message: string = 'Connection Error', statusCode: number = 400) { 11 | super(message, statusCode); 12 | } 13 | } 14 | 15 | export class MindmapException extends HttpException { 16 | constructor(message: string = 'Mindmap Error', statusCode: number = 400) { 17 | super(message, statusCode); 18 | } 19 | } 20 | 21 | export class NodeException extends HttpException { 22 | constructor(message: string = 'Node Error', statusCode: number = 400) { 23 | super(message, statusCode); 24 | } 25 | } 26 | 27 | export class UserException extends HttpException { 28 | constructor(message: string = 'User Error', statusCode: number = 400) { 29 | super(message, statusCode); 30 | } 31 | } 32 | 33 | export class DashboardException extends HttpException { 34 | constructor(message: string = 'Dashboard Error', statusCode: number = 400) { 35 | super(message, statusCode); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /BE/apps/socket-server/src/pipes/mindmap.validation.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, PipeTransform, BadRequestException } from '@nestjs/common'; 2 | import { plainToInstance } from 'class-transformer'; 3 | import { validate } from 'class-validator'; 4 | import { NodeDto } from './../modules/map/dto'; 5 | 6 | @Injectable() 7 | export class MindmapValidationPipe implements PipeTransform { 8 | async transform(value: any): Promise { 9 | try { 10 | const parsedValue = typeof value === 'string' ? JSON.parse(value) : value; 11 | 12 | for (const [key, nodeData] of Object.entries(parsedValue)) { 13 | const node = plainToInstance(NodeDto, nodeData); 14 | const errors = await validate(node); 15 | 16 | if (errors.length) { 17 | const errorMessages = errors 18 | .map((err) => Object.values(err.constraints)) 19 | .flat() 20 | .join(', '); 21 | throw new BadRequestException(`Validation failed for node ${key}: ${errorMessages}`); 22 | } 23 | } 24 | 25 | return parsedValue; 26 | } catch (error) { 27 | if (error instanceof BadRequestException) throw error; 28 | throw new BadRequestException('Invalid mindmap data format'); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /client/src/store/createSharedSlice.ts: -------------------------------------------------------------------------------- 1 | import { ConnectionStore } from "@/types/store"; 2 | import { NavigateFunction } from "react-router-dom"; 3 | import { StateCreator } from "zustand"; 4 | 5 | type NodeError = { 6 | message: string; 7 | status: string; 8 | }; 9 | 10 | export interface SharedSlice { 11 | createConnection: (navigate: NavigateFunction, targetMode: string) => void; 12 | propagateError: (error: string, status: string) => void; 13 | nodeError: NodeError[]; 14 | } 15 | export const createSharedSlice: StateCreator = (set, get) => ({ 16 | nodeError: [], 17 | createConnection: async (navigate: NavigateFunction, targetMode: string) => { 18 | try { 19 | const newMindMapConnectionId = await get().handleConnection(); 20 | get().token 21 | ? get().addOwnedMindMap(newMindMapConnectionId) 22 | : get().addOwnedMindMapForGuest(newMindMapConnectionId); 23 | get().connectSocket(newMindMapConnectionId); 24 | navigate(`/mindmap/${newMindMapConnectionId}?mode=${targetMode}`); 25 | } catch (error) { 26 | throw error; 27 | } 28 | }, 29 | 30 | propagateError: (message, status) => { 31 | set({ nodeError: [...get().nodeError, { message, status }] }); 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /nginx/dev.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | 4 | server_name localhost; 5 | client_max_body_size 100M; 6 | location / { 7 | # 요청 파일이 없을 경우 /index.html로 리다이렉트 8 | proxy_pass http://fe:5173; 9 | proxy_http_version 1.1; 10 | proxy_set_header Upgrade $http_upgrade; 11 | proxy_set_header Connection 'upgrade'; 12 | proxy_set_header Host $host; 13 | proxy_cache_bypass $http_upgrade; 14 | } 15 | 16 | location /api { 17 | proxy_pass http://api-server:3000; 18 | proxy_http_version 1.1; 19 | proxy_set_header Upgrade $http_upgrade; 20 | proxy_set_header Connection 'upgrade'; 21 | proxy_set_header Host $host; 22 | proxy_cache_bypass $http_upgrade; 23 | } 24 | 25 | location /socket.io { 26 | proxy_pass http://socket-server:4000; 27 | proxy_http_version 1.1; 28 | proxy_set_header Upgrade $http_upgrade; 29 | proxy_set_header Connection 'upgrade'; 30 | proxy_set_header Host $host; 31 | proxy_cache_bypass $http_upgrade; 32 | } 33 | } -------------------------------------------------------------------------------- /client/src/konva_mindmap/utils/getNewNodePosition.ts: -------------------------------------------------------------------------------- 1 | import { NodeData } from "@/types/Node"; 2 | import { findRootNodeKey } from "./findRootNodeKey"; 3 | import { calculateVector } from "./vector"; 4 | import { NODE_RADIUS } from "./nodeAttrs"; 5 | 6 | const lineLength = [360, 360, 360, 270]; 7 | const angle = [104, 105, 103, 104]; 8 | const distance = [240, 200, 160, 120]; 9 | 10 | export function getNewNodePosition(data: NodeData, nodeId: number) { 11 | const rootKey = findRootNodeKey(data); 12 | const node = data[nodeId]; 13 | const children = node.children; 14 | const depth = node.depth; 15 | 16 | if (!children.length) { 17 | if (node.id === rootKey) 18 | return { 19 | x: node.location.x + NODE_RADIUS(depth) * 7, 20 | y: node.location.y, 21 | }; 22 | const { x, y } = calculateVector(data[rootKey].location, node.location, -60, lineLength[depth - 1]); 23 | return node ? { x: node.location.x + x, y: node.location.y + y } : { x: 0, y: 0 }; 24 | } 25 | const lastChildren = data[children[children.length - 1]]; 26 | const uv = calculateVector(node.location, lastChildren.location, angle[depth - 1], distance[depth - 1]); 27 | return { 28 | x: lastChildren.location.x + uv.x, 29 | y: lastChildren.location.y + uv.y, 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /client/src/store/useConnectionStore.ts: -------------------------------------------------------------------------------- 1 | import { createRoleSlice } from "@/store/createRoleSlice"; 2 | import { createSocketSlice } from "@/store/createSocketSlice"; 3 | import { createJSONStorage, devtools, persist } from "zustand/middleware"; 4 | import { create } from "zustand"; 5 | import { createMindMapOwnershipSlice } from "@/store/createMindMapOwnershipSlice"; 6 | import { ConnectionStore } from "@/types/store"; 7 | import { createSharedSlice } from "@/store/createSharedSlice"; 8 | import { createAuthSlice } from "@/store/createAuthSlice"; 9 | 10 | export const useConnectionStore = create()( 11 | devtools( 12 | persist( 13 | (...a) => ({ 14 | ...createRoleSlice(...a), 15 | ...createSocketSlice(...a), 16 | ...createSharedSlice(...a), 17 | ...createAuthSlice(...a), 18 | ...createMindMapOwnershipSlice(...a), 19 | }), 20 | { 21 | name: "connectionStore", 22 | partialize: (state) => { 23 | return { 24 | token: state.token, 25 | ownedMindMap: state.ownedMindMap, 26 | ownedMindMapForGuest: state.ownedMindMapForGuest, 27 | }; 28 | }, 29 | storage: createJSONStorage(() => localStorage), 30 | }, 31 | ), 32 | ), 33 | ); 34 | -------------------------------------------------------------------------------- /BE/apps/socket-server/src/exceptions/exception.ts: -------------------------------------------------------------------------------- 1 | import { WsException } from '@nestjs/websockets'; 2 | 3 | export class InvalidConnectionIdException extends WsException { 4 | constructor() { 5 | super('Invalid connection id'); 6 | } 7 | } 8 | 9 | export class NodeNotFoundException extends WsException { 10 | constructor(nodeId: number) { 11 | super('해당 노드를 찾을 수 없습니다. : ' + nodeId); 12 | } 13 | } 14 | 15 | export class DatabaseException extends WsException { 16 | constructor(message: string) { 17 | super(message); 18 | } 19 | } 20 | 21 | export class JoinRoomException extends WsException { 22 | constructor() { 23 | super('Join room failed'); 24 | } 25 | } 26 | 27 | export class AiRequestException extends WsException { 28 | constructor(message: string) { 29 | super(`AI request failed : ${message}`); 30 | } 31 | } 32 | 33 | export class InvalidTokenException extends WsException { 34 | constructor() { 35 | super('Invalid token'); 36 | } 37 | } 38 | 39 | export class UpdateTitleException extends WsException { 40 | constructor(message: string) { 41 | super(`Update title failed : ${message}`); 42 | } 43 | } 44 | 45 | export class UnauthorizedException extends WsException { 46 | constructor() { 47 | super('권한이 없습니다.'); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /client/src/components/Sidebar/index.tsx: -------------------------------------------------------------------------------- 1 | import logo from "@/assets/logo.png"; 2 | import Overview from "@/components/Sidebar/Overview"; 3 | import { useNavigate } from "react-router-dom"; 4 | import ToggleButton from "./ToggleButton"; 5 | 6 | type SidebarProps = { 7 | isSidebarOpen: boolean; 8 | toggleSidebar: () => void; 9 | }; 10 | 11 | export default function Sidebar({ isSidebarOpen, toggleSidebar }: SidebarProps) { 12 | const navigate = useNavigate(); 13 | 14 | const animateOptions = isSidebarOpen ? "translate-x-0 opacity-100" : "-translate-x-full opacity-0"; 15 | const transitionClasses = "transition-all duration-300 ease-in-out"; 16 | 17 | return ( 18 | <> 19 | 31 | 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /client/src/konva_mindmap/utils/points.ts: -------------------------------------------------------------------------------- 1 | import { Location } from "@/konva_mindmap/types/location"; 2 | 3 | // from 원의 중점에서 to 원의 중점으로 이어지는 선에서 from 선의 위치를 원의 중점이 아닌 가장자리의 위치 구하는 함수 4 | export function getCircleEdgePoint(from: Location, to: Location, fromRadius: number, toRadius: number) { 5 | const dx = to.x - from.x; 6 | const dy = to.y - from.y; 7 | 8 | const vector = Math.sqrt(dx * dx + dy * dy); 9 | if (fromRadius + toRadius > vector) { 10 | return { 11 | xEdgePoint: 0, 12 | yEdgePoint: 0, 13 | }; 14 | } 15 | 16 | const xEdgePoint = Math.ceil(from.x + (fromRadius * dx) / vector); 17 | const yEdgePoint = Math.ceil(from.y + (fromRadius * dy) / vector); 18 | 19 | return { 20 | xEdgePoint, 21 | yEdgePoint, 22 | }; 23 | } 24 | 25 | // Point의 인자로 만들어주는 함수 26 | // 각각의 원에 대해서 가장자리 쪽으로 갈 수 있게끔 해줌 27 | export function getLinePoints(from: Location, to: Location, fromRadius: number, toRadius: number) { 28 | const fromCircleEdgePoint = getCircleEdgePoint(from, to, fromRadius, toRadius); 29 | const toCircleEdgePoint = getCircleEdgePoint(to, from, toRadius, fromRadius); 30 | return [ 31 | fromCircleEdgePoint.xEdgePoint, 32 | fromCircleEdgePoint.yEdgePoint, 33 | toCircleEdgePoint.xEdgePoint, 34 | toCircleEdgePoint.yEdgePoint, 35 | ]; 36 | } 37 | -------------------------------------------------------------------------------- /client/src/index.css: -------------------------------------------------------------------------------- 1 | @import url("https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"); 2 | @tailwind base; 3 | @tailwind components; 4 | @tailwind utilities; 5 | 6 | @layer utilities { 7 | .no-scrollbar ::-webkit-scrollbar { 8 | display: none; 9 | } 10 | .no-scrollbar { 11 | -ms-overflow-style: none; /* IE and Edge */ 12 | scrollbar-width: none; /* Firefox */ 13 | } 14 | } 15 | html, 16 | body { 17 | margin: 0; 18 | padding: 0; 19 | width: 100vw; 20 | height: 100vh; 21 | min-width: 1280px; 22 | min-height: 720px; 23 | max-width: 100vw; 24 | max-height: 100vh; 25 | box-sizing: border-box; 26 | } 27 | 28 | :root { 29 | font-family: Pretendard, sans-serif; 30 | line-height: 1.5; 31 | margin: 0; 32 | padding: 0; 33 | box-sizing: border-box; 34 | color-scheme: light dark; 35 | color: rgba(255, 255, 255); 36 | background-color: #262837; 37 | 38 | font-synthesis: none; 39 | text-rendering: optimizeLegibility; 40 | -webkit-font-smoothing: antialiased; 41 | -moz-osx-font-smoothing: grayscale; 42 | } 43 | 44 | @layer base { 45 | body { 46 | user-select: none; 47 | } 48 | a { 49 | text-decoration: none; 50 | } 51 | img { 52 | user-drag: none; 53 | pointer-events: none; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /BE/libs/entity/src/node.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | DeleteDateColumn, 5 | Entity, 6 | PrimaryGeneratedColumn, 7 | Tree, 8 | TreeChildren, 9 | TreeParent, 10 | UpdateDateColumn, 11 | ManyToOne, 12 | JoinColumn, 13 | } from 'typeorm'; 14 | import { Mindmap } from '.'; 15 | 16 | @Entity('node') 17 | @Tree('closure-table') 18 | export class Node { 19 | @PrimaryGeneratedColumn() 20 | id: number; 21 | 22 | @Column({ type: 'varchar', length: 128 }) 23 | keyword: string; 24 | 25 | @Column({ name: 'location_x', type: 'float', default: 0 }) 26 | locationX: number; 27 | 28 | @Column({ name: 'location_y', type: 'float', default: 0 }) 29 | locationY: number; 30 | 31 | @Column({ type: 'tinyint' }) 32 | depth: number; 33 | 34 | @CreateDateColumn({ type: 'timestamp', name: 'create_date' }) 35 | createDate: Date; 36 | 37 | @UpdateDateColumn({ type: 'timestamp', name: 'modified_date' }) 38 | modifiedDate: Date; 39 | 40 | @DeleteDateColumn() 41 | deletedAt: Date | null; 42 | 43 | @TreeParent() 44 | parent: Node; 45 | 46 | @TreeChildren({ cascade: true }) 47 | children: Node[]; 48 | 49 | @ManyToOne(() => Mindmap, (mindmap) => mindmap.nodes, { onDelete: 'CASCADE' }) 50 | @JoinColumn({ name: 'mindmap_id' }) 51 | mindmap: Mindmap; 52 | } 53 | -------------------------------------------------------------------------------- /BE/libs/entity/src/mindmap.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | DeleteDateColumn, 5 | Entity, 6 | OneToMany, 7 | PrimaryGeneratedColumn, 8 | UpdateDateColumn, 9 | } from 'typeorm'; 10 | 11 | import { UserMindmapRole } from './user.mindmap.role'; 12 | import { Node } from './node.entity'; 13 | 14 | @Entity('mindmap') 15 | export class Mindmap { 16 | @PrimaryGeneratedColumn() 17 | id: number; 18 | 19 | @Column({ type: 'varchar', length: 32, default: '제목없음' }) 20 | title: string; 21 | 22 | @Column({ type: 'text' }) 23 | content: string; 24 | 25 | @Column({ name: 'ai_content', type: 'text' }) 26 | aiContent: string; 27 | 28 | @Column({ name: 'ai_count', default: 5 }) 29 | aiCount: number; 30 | 31 | @OneToMany(() => UserMindmapRole, (userMindmapRole) => userMindmapRole.mindmap, { 32 | cascade: true, 33 | onDelete: 'CASCADE', 34 | }) 35 | userMindmapRoles: UserMindmapRole[]; 36 | 37 | @Column({ name: 'connection_id' }) 38 | connectionId: string; 39 | 40 | @OneToMany(() => Node, (node) => node.mindmap, { 41 | cascade: true, 42 | onDelete: 'CASCADE', 43 | }) 44 | nodes: Node[]; 45 | 46 | @CreateDateColumn({ type: 'timestamp', name: 'create_date' }) 47 | createdDate: Date; 48 | 49 | @UpdateDateColumn({ type: 'timestamp', name: 'modified_date' }) 50 | modifiedDate: Date; 51 | 52 | @DeleteDateColumn() 53 | deletedAt: Date | null; 54 | } 55 | -------------------------------------------------------------------------------- /client/src/components/MindMapHeader/Profile.tsx: -------------------------------------------------------------------------------- 1 | import LoginModal from "@/components/Modal/LoginModal"; 2 | import { Button } from "@headlessui/react"; 3 | import { createPortal } from "react-dom"; 4 | import useModal from "@/hooks/useModal"; 5 | import ProfileModal from "@/components/Modal/ProfileModal"; 6 | import { FaUserCircle } from "react-icons/fa"; 7 | import { useConnectionStore } from "@/store/useConnectionStore"; 8 | 9 | export default function Profile() { 10 | const { open: loginModal, openModal: openLoginModal, closeModal: closeLoginModal } = useModal(); 11 | const { open: profileModal, openModal: openProfileModal, closeModal: closeProfileModal } = useModal(); 12 | const isAuthenticated = useConnectionStore((state) => state.token); 13 | 14 | function handleProfileModal(e) { 15 | e.stopPropagation(); 16 | if (isAuthenticated) { 17 | profileModal ? closeProfileModal() : openProfileModal(); 18 | return; 19 | } 20 | openLoginModal(); 21 | } 22 | return ( 23 | <> 24 |
25 | 28 | 29 |
30 | {createPortal(, document.body)} 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /BE/libs/config/src/logger.config.ts: -------------------------------------------------------------------------------- 1 | import * as DailyRotateFile from 'winston-daily-rotate-file'; 2 | import * as winston from 'winston'; 3 | import { utilities } from 'nest-winston'; 4 | import { ConfigService } from '@nestjs/config'; 5 | 6 | const logDir = __dirname + '/../../logs'; 7 | 8 | const dailyOptions = (level: string) => { 9 | return { 10 | level, 11 | datePattern: 'YYYY-MM-DD', 12 | dirname: logDir + `/${level}`, 13 | filename: `./logs/${level}-%DATE%.log`, 14 | zippedArchive: true, 15 | maxSize: '20m', 16 | maxFiles: '14d', 17 | }; 18 | }; 19 | 20 | export const getWinstonConfig = (configService: ConfigService) => { 21 | const isProduction = configService.get('NODE_ENV') === 'production'; 22 | 23 | return { 24 | transports: [ 25 | new winston.transports.Console({ 26 | level: isProduction ? 'info' : 'silly', 27 | format: isProduction 28 | ? winston.format.simple() 29 | : winston.format.combine( 30 | winston.format.timestamp(), 31 | winston.format.ms(), 32 | utilities.format.nestLike('boomap', { 33 | colors: true, 34 | prettyPrint: true, 35 | }), 36 | ), 37 | }), 38 | new DailyRotateFile(dailyOptions('info')), 39 | new DailyRotateFile(dailyOptions('warn')), 40 | new DailyRotateFile(dailyOptions('error')), 41 | ], 42 | }; 43 | }; 44 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/ai/ai.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Logger, Post, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common'; 2 | import { AiService } from './ai.service'; 3 | import { FileInterceptor } from '@nestjs/platform-express'; 4 | import { AuthGuard } from '@nestjs/passport'; 5 | import { User } from '../../decorators'; 6 | import { MAX_FILE_SIZE } from 'apps/api-server/src/common/constant'; 7 | import { AudioFileValidationPipe } from '../../pipes'; 8 | import { AudioUploadDto } from './dto/audio.upload.dto'; 9 | import { AiDto } from './dto/ai.dto'; 10 | 11 | @Controller('ai') 12 | export class AiController { 13 | private readonly logger = new Logger(AiController.name); 14 | constructor(private readonly aiService: AiService) {} 15 | 16 | @Post('audio') 17 | @UseGuards(AuthGuard('jwt')) 18 | @UseInterceptors(FileInterceptor('aiAudio', { limits: { fileSize: MAX_FILE_SIZE } })) 19 | async uploadAudioFile( 20 | @UploadedFile(new AudioFileValidationPipe()) audioFile: Express.Multer.File, 21 | @User() user: { id: number; email: string }, 22 | @Body() audioUploadDto: AudioUploadDto, 23 | ) { 24 | this.logger.log(`User ${user.id} uploaded audio file`); 25 | await this.aiService.requestClovaSpeech(audioFile, audioUploadDto); 26 | return; 27 | } 28 | 29 | @Post('openai') 30 | @UseGuards(AuthGuard('jwt')) 31 | async requestOpenAi(@Body() aiDto: AiDto) { 32 | await this.aiService.requestOpenAi(aiDto); 33 | return; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /client/src/components/Modal/GuestNewMindMapModal.tsx: -------------------------------------------------------------------------------- 1 | import Modal from "@/components/common/Modal"; 2 | import { Button } from "@headlessui/react"; 3 | import { createPortal } from "react-dom"; 4 | import warnIcon from "@/assets/warning.png"; 5 | import { useNavigate } from "react-router-dom"; 6 | import { useConnectionStore } from "@/store/useConnectionStore"; 7 | 8 | export default function GuestNewMindMapModal({ open, closeModal, openLogin }) { 9 | const navigate = useNavigate(); 10 | const createConnection = useConnectionStore((state) => state.createConnection); 11 | 12 | return ( 13 | <> 14 | {createPortal( 15 | 16 |
17 | www 18 |

19 | 로그인하지 않은 상태로 마인드맵을 만들면 {"\n"} 24시간 이후에 사라져요 20 |

21 | 24 | 30 |
31 |
, 32 | document.body, 33 | )} 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /client/src/konva_mindmap/components/EditableTextInput.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | import { Html } from "react-konva-utils"; 3 | 4 | interface EditableTextInputProps { 5 | value: string; 6 | onChange: (e: React.ChangeEvent) => void; 7 | onKeyDown: (e: React.KeyboardEvent) => void; 8 | onBlur: () => void; 9 | offsetX: number; 10 | offsetY: number; 11 | width: number; 12 | focus: boolean; 13 | scale: number; 14 | } 15 | 16 | export default function EditableTextInput({ 17 | value, 18 | onChange, 19 | onKeyDown, 20 | onBlur, 21 | offsetX, 22 | offsetY, 23 | width, 24 | focus, 25 | scale, 26 | }: EditableTextInputProps) { 27 | const inputRef = useRef(null); 28 | const fontSize = scale >= 1 ? 16 : 16 / scale; 29 | 30 | useEffect(() => { 31 | if (inputRef.current && focus) { 32 | inputRef.current.select(); 33 | } 34 | }, [focus]); 35 | 36 | return ( 37 | 38 | 49 | 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/ai/dto/openai.request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ResponseFormatText } from 'openai/resources'; 2 | 3 | export class OpenAiRequestDto { 4 | private model = 'gpt-4o-mini'; 5 | private response_format: ResponseFormatText = { type: 'text' }; 6 | private temperature = 1; 7 | private max_tokens = 4096; 8 | private top_p = 1; 9 | private frequency_penalty = 0; 10 | private presence_penalty = 0; 11 | private messages: any[]; 12 | private tools: any[]; 13 | 14 | setPrompt(prompt: string) { 15 | this.messages = [ 16 | { 17 | role: 'system', 18 | content: [ 19 | { 20 | text: prompt, 21 | type: 'text', 22 | }, 23 | ], 24 | }, 25 | ]; 26 | } 27 | 28 | setAiContent(aiContent: string) { 29 | this.messages.push({ 30 | role: 'user', 31 | content: [ 32 | { 33 | text: aiContent, 34 | type: 'text', 35 | }, 36 | ], 37 | }); 38 | } 39 | 40 | setTools(tool: any) { 41 | this.tools = []; 42 | this.tools.push(tool); 43 | } 44 | 45 | toObject() { 46 | return { 47 | model: this.model, 48 | response_format: this.response_format, 49 | temperature: this.temperature, 50 | max_tokens: this.max_tokens, 51 | top_p: this.top_p, 52 | frequency_penalty: this.frequency_penalty, 53 | presence_penalty: this.presence_penalty, 54 | messages: this.messages, 55 | tools: this.tools, 56 | }; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/connection/connection.controller.ts: -------------------------------------------------------------------------------- 1 | import { OptionalJwtGuard } from '@app/jwt'; 2 | import { BadRequestException, Controller, Get, Post, Query, UseGuards } from '@nestjs/common'; 3 | import { ConnectionService } from './connection.service'; 4 | import { User } from '../../decorators'; 5 | import { AuthGuard } from '@nestjs/passport'; 6 | import { ConnectionQueryDto } from './dto/connection.query.dto'; 7 | 8 | interface UserDto { 9 | id: number; 10 | email: string; 11 | } 12 | 13 | @Controller('connection') 14 | export class ConnectionController { 15 | constructor(private readonly connectionService: ConnectionService) {} 16 | 17 | @Post() 18 | @UseGuards(OptionalJwtGuard) 19 | async createMindMap(@User() user: UserDto | null) { 20 | return user 21 | ? await this.connectionService.createConnection(user.id) 22 | : await this.connectionService.createGuestConnection(); 23 | } 24 | 25 | @Get() 26 | @UseGuards(AuthGuard('jwt')) 27 | async getConnection(@Query() queryDto: ConnectionQueryDto, @User() user: UserDto) { 28 | const { type, id } = queryDto; 29 | console.log('type:', type); 30 | console.log('id:', id); 31 | switch (type) { 32 | case 'connection': 33 | return await this.connectionService.getConnection(id as string, user.id); 34 | case 'mindmap': 35 | return await this.connectionService.setConnection(id as number, user.id); 36 | default: 37 | throw new BadRequestException('Invalid query type'); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /client/src/konva_mindmap/components/DrawMindMap.tsx: -------------------------------------------------------------------------------- 1 | import MindMapNode from "@/konva_mindmap/components/MindMapNode"; 2 | import ConnectedLine from "@/konva_mindmap/components/ConnectedLine"; 3 | import { Node, NodeData } from "@/types/Node"; 4 | import React from "react"; 5 | import { CONNECTED_LINE_FROM, CONNECTED_LINE_TO } from "@/konva_mindmap/utils/nodeAttrs"; 6 | 7 | type MindMapProps = { 8 | data: NodeData; 9 | root: Node; 10 | depth?: number; 11 | parentNode?: any; 12 | update?: (id: number, node: Node) => void; 13 | dragmode: boolean; 14 | scale: number; 15 | }; 16 | 17 | export default function DrawMindMap({ data, root, depth = 0, parentNode, dragmode, scale }: MindMapProps) { 18 | return ( 19 | <> 20 | {parentNode && ( 21 | 27 | )} 28 | 29 | {root.children?.map((childNode, index) => ( 30 | 31 | 39 | 40 | ))} 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /client/src/konva_mindmap/components/NodeTool.tsx: -------------------------------------------------------------------------------- 1 | import { Location } from "@/konva_mindmap/types/location"; 2 | import { Button } from "@headlessui/react"; 3 | import { FaAddressBook, FaPencilAlt, FaRegTrashAlt } from "react-icons/fa"; 4 | import { TbCirclePlus2 } from "react-icons/tb"; 5 | import { Html } from "react-konva-utils"; 6 | 7 | type NodeToolProps = { 8 | offset: Location; 9 | visible: boolean; 10 | handleEdit: () => void; 11 | handleAdd: () => void; 12 | handleDelete: () => void; 13 | }; 14 | export default function NodeTool({ offset, visible, handleEdit, handleAdd, handleDelete }: NodeToolProps) { 15 | const visibility = visible ? "" : "hidden"; 16 | return ( 17 | 18 |
21 | 24 | 27 | 30 |
31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /client/src/pages/layout/index.tsx: -------------------------------------------------------------------------------- 1 | import Error from "@/components/common/Error"; 2 | import Sidebar from "@/components/Sidebar"; 3 | import { useSideBar } from "@/store/useSideBar"; 4 | import { ErrorBoundary } from "react-error-boundary"; 5 | import { Outlet } from "react-router-dom"; 6 | import { useConnectionStore } from "@/store/useConnectionStore"; 7 | import OfflineModal from "@/components/Modal/OfflineModal"; 8 | import useWindowEventListener from "@/hooks/useWindowEventListener"; 9 | import { useState } from "react"; 10 | import NotFound from "@/components/common/NotFound"; 11 | 12 | export default function Layout() { 13 | const sidebar = useSideBar(); 14 | const connectionStatus = useConnectionStore((state) => state.connectionStatus); 15 | const [modalOpen, setModalOpen] = useState(false); 16 | 17 | useWindowEventListener("offline", () => { 18 | setModalOpen(true); 19 | }); 20 | 21 | if (connectionStatus === "error") return ; 22 | if (connectionStatus === "notFound") return ; 23 | 24 | return ( 25 | }> 26 | {modalOpen && setModalOpen(false)} />} 27 |
28 |
29 | 30 | 31 |
32 |
33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /BE/apps/socket-server/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule, ConfigService } from '@nestjs/config'; 3 | import { WinstonModule } from 'nest-winston'; 4 | import { RedisModule } from '@liaoliaots/nestjs-redis'; 5 | import { TypeOrmModule } from '@nestjs/typeorm'; 6 | import { getWinstonConfig, getTypeOrmConfig, getRedisConfig, getJwtConfig } from '@app/config'; 7 | import { MapModule } from './modules/map/map.module'; 8 | 9 | import { join } from 'path'; 10 | import { JwtModule } from '@nestjs/jwt'; 11 | import { SubscriberModule } from './modules/subscriber/subscriber.module'; 12 | import { PublisherModule } from '@app/publisher'; 13 | 14 | @Module({ 15 | imports: [ 16 | ConfigModule.forRoot({ 17 | isGlobal: true, 18 | envFilePath: [join(__dirname, '..', '..', '..', '.env')], 19 | }), 20 | WinstonModule.forRootAsync({ 21 | inject: [ConfigService], 22 | useFactory: getWinstonConfig, 23 | }), 24 | RedisModule.forRootAsync({ 25 | inject: [ConfigService], 26 | useFactory: getRedisConfig, 27 | }), 28 | TypeOrmModule.forRootAsync({ 29 | inject: [ConfigService], 30 | useFactory: getTypeOrmConfig, 31 | }), 32 | JwtModule.registerAsync({ 33 | global: true, 34 | inject: [ConfigService], 35 | useFactory: getJwtConfig, 36 | }), 37 | MapModule, 38 | SubscriberModule, 39 | PublisherModule, 40 | ], 41 | controllers: [], 42 | providers: [], 43 | exports: [], 44 | }) 45 | export class AppModule {} 46 | -------------------------------------------------------------------------------- /BE/apps/socket-server/src/pipes/ws.validation.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, PipeTransform, ArgumentMetadata } from '@nestjs/common'; 2 | import { WsException } from '@nestjs/websockets'; 3 | import { plainToInstance } from 'class-transformer'; 4 | import { validate } from 'class-validator'; 5 | 6 | @Injectable() 7 | export class WsValidationPipe implements PipeTransform { 8 | async transform(value: any, metadata: ArgumentMetadata): Promise { 9 | if (!value) { 10 | throw new WsException({ 11 | code: 'VALIDATION_ERROR', 12 | message: 'No data provided', 13 | }); 14 | } 15 | 16 | let parsedValue: any; 17 | try { 18 | parsedValue = typeof value === 'string' ? JSON.parse(value) : value; 19 | } catch (error) { 20 | throw new WsException({ 21 | code: 'VALIDATION_ERROR', 22 | message: `Invalid JSON format: ${error.message}`, 23 | }); 24 | } 25 | 26 | if (!metadata.metatype) { 27 | return parsedValue; 28 | } 29 | 30 | const object = plainToInstance(metadata.metatype, parsedValue, { 31 | excludeExtraneousValues: true, 32 | }); 33 | 34 | const errors = await validate(object); 35 | 36 | if (errors.length > 0) { 37 | throw new WsException({ 38 | code: 'VALIDATION_ERROR', 39 | message: 'Validation failed', 40 | details: errors.map((err) => ({ 41 | property: err.property, 42 | constraints: err.constraints, 43 | })), 44 | }); 45 | } 46 | 47 | return object; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /nginx/default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name boomap.site www.boomap.site; 4 | client_max_body_size 100M; 5 | location / { 6 | return 301 https://$host$request_uri; 7 | } 8 | } 9 | 10 | server { 11 | listen 443 ssl; 12 | 13 | # react 빌드 정적파일 경로 14 | root /var/www/html; 15 | index index.html; 16 | 17 | server_name boomap.site www.boomap.site; 18 | 19 | # SSL 인증서 경로 설정 20 | ssl_certificate /etc/letsencrypt/live/boomap.site/fullchain.pem; 21 | ssl_certificate_key /etc/letsencrypt/live/boomap.site/privkey.pem; 22 | 23 | client_max_body_size 100M; 24 | 25 | location / { 26 | # 요청 파일이 없을 경우 /index.html로 리다이렉트 27 | try_files $uri $uri/ /index.html; 28 | } 29 | 30 | location /api { 31 | proxy_pass http://api:3000; 32 | proxy_http_version 1.1; 33 | proxy_set_header Upgrade $http_upgrade; 34 | proxy_set_header Connection 'upgrade'; 35 | proxy_set_header Host $host; 36 | proxy_cache_bypass $http_upgrade; 37 | } 38 | 39 | location /socket.io { 40 | proxy_pass http://socket:4000; 41 | proxy_http_version 1.1; 42 | proxy_set_header Upgrade $http_upgrade; 43 | proxy_set_header Connection 'upgrade'; 44 | proxy_set_header Host $host; 45 | proxy_cache_bypass $http_upgrade; 46 | } 47 | } -------------------------------------------------------------------------------- /client/src/components/MindMapHeader/MindMapHeaderButtons.tsx: -------------------------------------------------------------------------------- 1 | import { downloadURI } from "@/konva_mindmap/utils/download"; 2 | import { Button } from "@headlessui/react"; 3 | import useModal from "@/hooks/useModal"; 4 | import ShareModal from "../Modal/ShareModal"; 5 | import { LuShare, LuShare2 } from "react-icons/lu"; 6 | import { createPortal } from "react-dom"; 7 | import { useNodeListContext } from "@/store/NodeListProvider"; 8 | 9 | export default function MindMapHeaderButtons() { 10 | const { open, openModal, closeModal } = useModal(); 11 | const { title, stage } = useNodeListContext(); 12 | 13 | function handleExport() { 14 | const nodes = stage.current.children[0].children.length; 15 | if (nodes > 1) 16 | downloadURI(stage.current.getStage().toDataURL({ mimeType: "image/png", quality: 1, pixelRatio: 3 }), title); 17 | } 18 | 19 | return ( 20 | <> 21 |
22 | 29 | 36 |
37 | {createPortal(, document.body)} 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /client/src/konva_mindmap/utils/select.ts: -------------------------------------------------------------------------------- 1 | import { Location } from "@/konva_mindmap/types/location"; 2 | import { Node, NodeData } from "@/types/Node"; 3 | import Konva from "konva"; 4 | 5 | export function getSelectedNodes(layer: Konva.Layer, square) { 6 | if (!layer) return []; 7 | 8 | const nodes = layer.children.filter((node) => node.attrs.name === "node"); 9 | 10 | const rectBounds = { 11 | x1: square.x, 12 | y1: square.y, 13 | x2: square.x + square.width, 14 | y2: square.y + square.height, 15 | }; 16 | 17 | const selectedNodes = nodes.filter((node) => { 18 | const nodeCenter = { 19 | x: node.attrs.x, 20 | y: node.attrs.y, 21 | }; 22 | 23 | return ( 24 | nodeCenter.x >= rectBounds.x1 && 25 | nodeCenter.x <= rectBounds.x2 && 26 | nodeCenter.y >= rectBounds.y1 && 27 | nodeCenter.y <= rectBounds.y2 28 | ); 29 | }); 30 | 31 | return selectedNodes; 32 | } 33 | 34 | export function getMovedNodesLocation( 35 | data: NodeData, 36 | selectedGroup, 37 | node: Node, 38 | dx: number, 39 | dy: number, 40 | currentPos: Location, 41 | ) { 42 | const result = JSON.parse(JSON.stringify(data)); 43 | result[node.id].location = currentPos; 44 | selectedGroup.forEach((selectedId) => { 45 | if (selectedId !== node.id.toString()) { 46 | const selectedNode = result[parseInt(selectedId)]; 47 | result[parseInt(selectedId)].location = { 48 | x: selectedNode.location.x + dx, 49 | y: selectedNode.location.y + dy, 50 | }; 51 | } 52 | }); 53 | return result; 54 | } 55 | -------------------------------------------------------------------------------- /client/src/hooks/useUpload.ts: -------------------------------------------------------------------------------- 1 | import { useNodeListContext } from "@/store/NodeListProvider"; 2 | import { useConnectionStore } from "@/store/useConnectionStore"; 3 | import { useState } from "react"; 4 | 5 | export default function useUpload() { 6 | const [content, setContent] = useState(""); 7 | const role = useConnectionStore((state) => state.currentRole); 8 | const { aiCount } = useNodeListContext(); 9 | const [availabilityInform, setAvailabilityInform] = useState(""); 10 | const [errorMsg, setErrorMsg] = useState(""); 11 | const isAuthenticated = useConnectionStore((state) => state.token); 12 | 13 | const ownerAvailability = role === "owner"; 14 | 15 | function updateContent(content: string) { 16 | setContent(content); 17 | } 18 | 19 | function handleMouseEnter() { 20 | checkAvailability(); 21 | } 22 | 23 | function handleMouseLeave() { 24 | setAvailabilityInform(""); 25 | } 26 | 27 | function checkAvailability() { 28 | if (!isAuthenticated) { 29 | setAvailabilityInform("비회원은 AI 변환 기능을 사용할 수 없어요"); 30 | return; 31 | } 32 | if (!ownerAvailability) { 33 | setAvailabilityInform("마인드맵 소유자만 AI 변환을 할 수 있어요"); 34 | return; 35 | } 36 | if (!aiCount) { 37 | setAvailabilityInform("모든 AI 변환 요청을 다 사용했어요"); 38 | return; 39 | } 40 | } 41 | 42 | function updateErrorMsg(message: string) { 43 | setErrorMsg(message); 44 | } 45 | 46 | return { 47 | content, 48 | updateContent, 49 | handleMouseEnter, 50 | handleMouseLeave, 51 | availabilityInform, 52 | errorMsg, 53 | updateErrorMsg, 54 | }; 55 | } 56 | -------------------------------------------------------------------------------- /client/src/api/auth.api.ts: -------------------------------------------------------------------------------- 1 | import { instance } from "@/api"; 2 | import { useConnectionStore } from "@/store/useConnectionStore"; 3 | import { TokenRefresh, User } from "@/types/auth"; 4 | import { logOnDev } from "@/utils/logging"; 5 | import axios, { AxiosError, AxiosResponse } from "axios"; 6 | 7 | export const instanceForAuth = axios.create({ 8 | baseURL: import.meta.env.VITE_APP_API_SERVER_BASE_URL, 9 | timeout: 3000, 10 | withCredentials: true, 11 | headers: { 12 | "Content-Type": "application/json", 13 | }, 14 | }); 15 | 16 | instanceForAuth.interceptors.response.use( 17 | (response: AxiosResponse) => { 18 | const { method, url } = response.config; 19 | const { status } = response; 20 | 21 | logOnDev(`🚀 [API Response] ${method?.toUpperCase()} ${url} | Response ${status}`); 22 | 23 | return response; 24 | }, 25 | async (error: AxiosError | Error) => { 26 | if (axios.isAxiosError(error)) { 27 | logOnDev(`🚨 [API ERROR] ${error.message}`); 28 | useConnectionStore.getState().logout(); 29 | location.href = "/"; 30 | 31 | return Promise.reject(error); 32 | } 33 | logOnDev(`🚨 [API ERROR] ${error.message}`); 34 | }, 35 | ); 36 | 37 | export const tokenRefresh = async (): Promise => { 38 | const { data } = await instanceForAuth.post("/auth/refresh", {}, { withCredentials: true }); 39 | return data; 40 | }; 41 | 42 | export const getUser = async (): Promise => { 43 | const { data } = await instance.get("/user/info"); 44 | return data; 45 | }; 46 | 47 | export const signOut = async () => { 48 | return instanceForAuth.post("/auth/logout"); 49 | }; 50 | -------------------------------------------------------------------------------- /client/src/components/common/Toast/Toast.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | type ToastProps = { 4 | message: string; 5 | status: "success" | "fail" | "error"; 6 | onClose: () => void; 7 | }; 8 | 9 | export default function Toast({ message, status, onClose }: ToastProps) { 10 | const [progress, setProgress] = useState(0); 11 | const duration = 3000; 12 | 13 | useEffect(() => { 14 | const interval = duration / 100; 15 | const timer = setTimeout(onClose, duration); 16 | const progressInterval = setInterval(() => { 17 | setProgress((prev) => Math.min(prev + 1, 100)); 18 | }, interval); 19 | return () => { 20 | clearTimeout(timer); 21 | clearInterval(progressInterval); 22 | }; 23 | }, []); 24 | 25 | const textColor = { 26 | success: "text-blue-500", 27 | fail: "text-red-400", 28 | error: "text-yellow-500", // error 추가 29 | }[status]; 30 | 31 | const bgColor = { 32 | success: "bg-blue-500", 33 | fail: "bg-red-400", 34 | error: "bg-yellow-500", // error 추가 35 | }[status]; 36 | 37 | return ( 38 |
39 |

{message}

40 |
41 |
42 |
43 | 46 |
47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/dashboard/dashboard.service.ts: -------------------------------------------------------------------------------- 1 | import { NodeService } from './../node/node.service'; 2 | import { MindmapService } from './../mindmap/mindmap.service'; 3 | import { Injectable, Logger } from '@nestjs/common'; 4 | import { DashboardException } from '../../exceptions'; 5 | 6 | @Injectable() 7 | export class DashboardService { 8 | private readonly logger = new Logger(DashboardService.name); 9 | constructor( 10 | private readonly mindmapService: MindmapService, 11 | private readonly nodeService: NodeService, 12 | ) {} 13 | 14 | async findAll(userId: number) { 15 | try { 16 | const mindmapList = await this.mindmapService.findAllByUserId(userId); 17 | 18 | if (!mindmapList.length) { 19 | return []; 20 | } 21 | 22 | const mindmapIds = mindmapList.map((mindmap) => mindmap.id); 23 | const owners = await this.mindmapService.getOwner(mindmapIds); 24 | const keywords = await Promise.all(mindmapIds.map((id) => this.nodeService.findKeywordByMindmapId(id))); 25 | 26 | return mindmapList.map((mindmap, index) => ({ 27 | id: mindmap.id, 28 | connectionId: mindmap.connectionId, 29 | title: mindmap.title, 30 | keyword: keywords[index], 31 | createDate: mindmap.createdDate, 32 | modifiedDate: mindmap.modifiedDate, 33 | ownerName: owners.find((owner) => owner.mindmapId === mindmap.id)?.ownerName, 34 | ownerId: owners.find((owner) => owner.mindmapId === mindmap.id)?.userId, 35 | })); 36 | } catch (error) { 37 | Logger.error(error); 38 | throw new DashboardException('대시보드 조회에 실패했습니다.'); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /BE/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false, 20 | "paths": { 21 | "@app/config": [ 22 | "libs/config/src" 23 | ], 24 | "@app/config/*": [ 25 | "libs/config/src/*" 26 | ], 27 | "@app/typeorm": [ 28 | "libs/typeorm/src" 29 | ], 30 | "@app/typeorm/*": [ 31 | "libs/typeorm/src/*" 32 | ], 33 | "@app/logger": [ 34 | "libs/logger/src" 35 | ], 36 | "@app/logger/*": [ 37 | "libs/logger/src/*" 38 | ], 39 | "@app/entity": [ 40 | "libs/entity/src" 41 | ], 42 | "@app/entity/*": [ 43 | "libs/entity/src/*" 44 | ], 45 | "@app/interface": [ 46 | "libs/interface/src" 47 | ], 48 | "@app/interface/*": [ 49 | "libs/interface/src/*" 50 | ], 51 | "@app/jwt": [ 52 | "libs/jwt/src" 53 | ], 54 | "@app/jwt/*": [ 55 | "libs/jwt/src/*" 56 | ], 57 | "@app/publisher": [ 58 | "libs/publisher/src" 59 | ], 60 | "@app/publisher/*": [ 61 | "libs/publisher/src/*" 62 | ] 63 | } 64 | } 65 | } -------------------------------------------------------------------------------- /client/src/components/MindMapMainSection/index.tsx: -------------------------------------------------------------------------------- 1 | import MindMapHeader from "@/components/MindMapHeader"; 2 | import MindMapView from "@/components/MindMapMainSection/MindMapView"; 3 | import useSection from "@/hooks/useSection"; 4 | import { useEffect } from "react"; 5 | import { useParams } from "react-router-dom"; 6 | import ToastContainer from "../common/Toast/ToastContainer"; 7 | import { useConnectionStore } from "@/store/useConnectionStore"; 8 | import useToast from "@/hooks/useToast"; 9 | 10 | const modeView = { 11 | voiceupload: "음성 파일 업로드", 12 | listview: "리스트 보기", 13 | textupload: "텍스트 형식으로 업로드", 14 | }; 15 | 16 | export default function MindMapMainSection() { 17 | const mode = useSection().searchParams.get("mode") as keyof typeof modeView; 18 | const { mindMapId } = useParams<{ mindMapId: string }>(); 19 | const connectSocket = useConnectionStore((state) => state.connectSocket); 20 | const disconnectSocket = useConnectionStore((state) => state.disconnectSocket); 21 | const { toasts, setToasts } = useToast(); 22 | 23 | useEffect(() => { 24 | if (mindMapId) { 25 | connectSocket(mindMapId); 26 | } 27 | return () => { 28 | disconnectSocket(); 29 | }; 30 | }, [mindMapId, connectSocket, disconnectSocket]); 31 | 32 | return ( 33 | <> 34 | 35 |
36 |

{modeView[mode] || modeView.listview}

37 |
38 | 39 | 40 |
41 |
42 | 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /client/src/konva_mindmap/hooks/useCollisionDetection.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef, useEffect, useLayoutEffect } from "react"; 2 | import Konva from "konva"; 3 | import { isCollided, moveOnCollision } from "@/konva_mindmap/utils/collision"; 4 | import { Node, NodeData } from "@/types/Node"; 5 | import { throttle } from "@/konva_mindmap/utils/throttle"; 6 | 7 | export function useCollisionDetection(nodeData: NodeData, updateNode: (id: number, updates: Partial) => void) { 8 | const layer = useRef(null); 9 | 10 | const handleCollision = useCallback( 11 | (base, target) => { 12 | const newTargetPosition = moveOnCollision(target, base); 13 | updateNode(target.attrs.id, { location: newTargetPosition }); 14 | }, 15 | [nodeData, updateNode], 16 | ); 17 | 18 | const detectCollisions = useCallback( 19 | (layer) => { 20 | const nodes = layer.children.filter((child) => child.attrs.name === "node"); 21 | nodes.forEach((base) => { 22 | nodes.forEach((target) => { 23 | if (base !== target) { 24 | if (isCollided(base.children[0].getClientRect(), target.children[0].getClientRect())) { 25 | handleCollision(base, target); 26 | } 27 | } 28 | }); 29 | }); 30 | }, 31 | [handleCollision], 32 | ); 33 | 34 | const throttledDetectCollisions = useCallback(() => { 35 | throttle(() => { 36 | if (layer.current) { 37 | requestAnimationFrame(() => { 38 | detectCollisions(layer.current); 39 | }); 40 | } 41 | }, 16); 42 | }, [detectCollisions]); 43 | 44 | useLayoutEffect(() => { 45 | if (layer.current) { 46 | throttledDetectCollisions(); 47 | } 48 | }, [nodeData, layer, detectCollisions]); 49 | 50 | return layer; 51 | } 52 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | export default tseslint.config({ 18 | languageOptions: { 19 | // other options... 20 | parserOptions: { 21 | project: ['./tsconfig.node.json', './tsconfig.app.json'], 22 | tsconfigRootDir: import.meta.dirname, 23 | }, 24 | }, 25 | }) 26 | ``` 27 | 28 | - Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` 29 | - Optionally add `...tseslint.configs.stylisticTypeChecked` 30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: 31 | 32 | ```js 33 | // eslint.config.js 34 | import react from 'eslint-plugin-react' 35 | 36 | export default tseslint.config({ 37 | // Set the react version 38 | settings: { react: { version: '18.3' } }, 39 | plugins: { 40 | // Add the react plugin 41 | react, 42 | }, 43 | rules: { 44 | // other rules... 45 | // Enable its recommended rules 46 | ...react.configs.recommended.rules, 47 | ...react.configs['jsx-runtime'].rules, 48 | }, 49 | }) 50 | ``` 51 | -------------------------------------------------------------------------------- /client/src/store/createMindMapOwnershipSlice.ts: -------------------------------------------------------------------------------- 1 | import { DashBoard } from "@/konva_mindmap/types/dashboard"; 2 | import { ConnectionStore } from "@/types/store"; 3 | import { StateCreator } from "zustand"; 4 | 5 | export interface MindMapOwnershipSlice { 6 | ownedMindMap: string[]; 7 | ownedMindMapForGuest: string[]; 8 | addOwnedMindMap: (connectionId: string) => void; 9 | addOwnedMindMapForGuest: (connectionId: string) => void; 10 | updateOwnedMindMap: (mindMaps: DashBoard[]) => void; 11 | deleteOwnedMindMap: (connectionId: string) => void; 12 | deleteOwnedMindMapForGuest: (connectionId: string) => void; 13 | resetOwnedMindMap: () => void; 14 | } 15 | 16 | export const createMindMapOwnershipSlice: StateCreator = ( 17 | set, 18 | get, 19 | ) => ({ 20 | ownedMindMap: [], 21 | ownedMindMapForGuest: [], 22 | 23 | addOwnedMindMap: (connectionId: string) => set((state) => ({ ownedMindMap: [...state.ownedMindMap, connectionId] })), 24 | addOwnedMindMapForGuest: (connectionId: string) => 25 | set((state) => ({ ownedMindMapForGuest: [...state.ownedMindMapForGuest, connectionId] })), 26 | 27 | updateOwnedMindMap: (mindMaps: DashBoard[]) => { 28 | const userId = get().id; 29 | if (!userId) return; 30 | const userOwnedMaps = mindMaps.filter((map) => map.ownerId === userId).map((map) => map.connectionId); 31 | set({ ownedMindMap: userOwnedMaps }); 32 | }, 33 | 34 | deleteOwnedMindMap: (connectionId: string) => 35 | set((state) => ({ ownedMindMap: state.ownedMindMap.filter((map) => map !== connectionId) })), 36 | deleteOwnedMindMapForGuest: (connectionId: string) => 37 | set((state) => ({ 38 | ownedMindMapForGuest: state.ownedMindMapForGuest.filter((map) => map !== connectionId), 39 | })), 40 | 41 | resetOwnedMindMap: () => set({ ownedMindMap: [] }), 42 | }); 43 | -------------------------------------------------------------------------------- /client/src/hooks/useHistoryState.ts: -------------------------------------------------------------------------------- 1 | import { useConnectionStore } from "@/store/useConnectionStore"; 2 | import { useState, useCallback } from "react"; 3 | 4 | export default function useHistoryState(data: string) { 5 | const [history, setHistory] = useState([data]); 6 | const [pointer, setPointer] = useState(0); 7 | const handleSocketEvent = useConnectionStore((state) => state.handleSocketEvent); 8 | 9 | const saveHistory = useCallback( 10 | (data: string) => { 11 | setHistory((prevHistory) => [...prevHistory.slice(0, pointer + 1), data]); 12 | setPointer((p) => p + 1); 13 | }, 14 | [pointer], 15 | ); 16 | 17 | const overrideHistory = useCallback( 18 | (data: string) => { 19 | setHistory([data]); 20 | setPointer(0); 21 | }, 22 | [pointer], 23 | ); 24 | 25 | const undo = useCallback( 26 | (setData) => { 27 | if (!history[0] || pointer <= 0) return; 28 | const parsedData = JSON.parse(history[pointer - 1]); 29 | if (Object.keys(parsedData).length === 0) return; 30 | handleSocketEvent({ 31 | actionType: "updateNode", 32 | payload: parsedData, 33 | callback: () => { 34 | setData(parsedData); 35 | setPointer((p) => p - 1); 36 | }, 37 | }); 38 | }, 39 | [history, pointer], 40 | ); 41 | 42 | const redo = useCallback( 43 | (setData) => { 44 | if (pointer >= history.length - 1) return; 45 | const parsedData = JSON.parse(history[pointer + 1]); 46 | handleSocketEvent({ 47 | actionType: "updateNode", 48 | payload: parsedData, 49 | callback: () => { 50 | setData(parsedData); 51 | setPointer((p) => p + 1); 52 | }, 53 | }); 54 | }, 55 | [history, pointer], 56 | ); 57 | 58 | return { saveHistory, overrideHistory, undo, redo, history }; 59 | } 60 | -------------------------------------------------------------------------------- /client/src/konva_mindmap/hooks/useCollisionDetectionForWorker.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef, useEffect, useState } from "react"; 2 | import Konva from "konva"; 3 | import { Node, NodeData } from "@/types/Node"; 4 | 5 | export function useCollisionDetection(nodeData: NodeData, updateNode: (id: number, updates: Partial) => void) { 6 | const [collisionWorker, setCollisionWorker] = useState(null); 7 | const layer = useRef(null); 8 | 9 | useEffect(() => { 10 | const worker = new Worker(new URL("../utils/collision-worker.ts", import.meta.url)); 11 | 12 | worker.onmessage = (event) => { 13 | if (event.data.type === "collisionResult") { 14 | event.data.collisions.forEach((collision) => { 15 | updateNode(collision.targetId, { 16 | location: { 17 | x: collision.newPosition.x, 18 | y: collision.newPosition.y, 19 | }, 20 | }); 21 | }); 22 | } 23 | }; 24 | 25 | setCollisionWorker(worker); 26 | 27 | return () => { 28 | worker.terminate(); 29 | }; 30 | }, []); 31 | 32 | const detectCollisions = useCallback(() => { 33 | if (layer.current && collisionWorker) { 34 | const nodes = layer.current.children 35 | .filter((child) => child.attrs.name === "node") 36 | .map((node) => ({ 37 | id: node.attrs.id, 38 | location: { 39 | x: node.attrs.x, 40 | y: node.attrs.y, 41 | }, 42 | rect: node.getClientRect(), 43 | })); 44 | 45 | collisionWorker.postMessage({ 46 | type: "detectCollisions", 47 | nodes, 48 | }); 49 | } 50 | }, [collisionWorker]); 51 | 52 | useEffect(() => { 53 | requestAnimationFrame(detectCollisions); 54 | }, [detectCollisions, layer, nodeData]); 55 | 56 | return layer; 57 | } 58 | -------------------------------------------------------------------------------- /client/src/konva_mindmap/test/line.test.ts: -------------------------------------------------------------------------------- 1 | // import { describe, it, expect } from "vitest"; 2 | // import { Location } from "@/konva_mindmap/types/location"; 3 | // import { getCircleEdgePoint, getLinePoints } from "@/konva_mindmap/utils/points"; 4 | 5 | // // 테스트용 데이터 6 | // const from: Location = { x: Math.random() * 100, y: Math.random() * 100 }; 7 | // const to: Location = { x: Math.random() * 100, y: Math.random() * 100 }; 8 | // const fromRadius = Math.random() * 50; 9 | // const toRadius = Math.random() * 50; 10 | 11 | // describe("ConnectedLine 유틸리티 함수", () => { 12 | // it("getCircleEdgePoint가 가장자리 포인트를 올바르게 계산해야 함", () => { 13 | // const edgePoint = getCircleEdgePoint(from, to, fromRadius, toRadius); 14 | 15 | // // 두 원의 반지름이 거리보다 큰 경우 (겹치는 경우) (0,0) 좌표 반환 16 | // const distance = Math.sqrt((to.x - from.x) ** 2 + (to.y - from.y) ** 2); 17 | // if (fromRadius + toRadius > distance) { 18 | // expect(edgePoint).toEqual({ xEdgePoint: 0, yEdgePoint: 0 }); 19 | // } else { 20 | // // 겹치지 않는 경우 예상 값 계산 21 | // const expectedXEdgePoint = Math.ceil(from.x + (fromRadius * (to.x - from.x)) / distance); 22 | // const expectedYEdgePoint = Math.ceil(from.y + (fromRadius * (to.y - from.y)) / distance); 23 | 24 | // expect(edgePoint.xEdgePoint).toBe(expectedXEdgePoint); 25 | // expect(edgePoint.yEdgePoint).toBe(expectedYEdgePoint); 26 | // } 27 | // }); 28 | 29 | // it("getLinePoints는 두 원의 가장자리 포인트를 반환해야 함", () => { 30 | // const points = getLinePoints(from, to, fromRadius, toRadius); 31 | 32 | // // from과 to에 대해 가장자리 포인트 계산 33 | // const fromEdge = getCircleEdgePoint(from, to, fromRadius, toRadius); 34 | // const toEdge = getCircleEdgePoint(to, from, toRadius, fromRadius); 35 | 36 | // expect(points).toEqual([fromEdge.xEdgePoint, fromEdge.yEdgePoint, toEdge.xEdgePoint, toEdge.yEdgePoint]); 37 | // }); 38 | // }); 39 | -------------------------------------------------------------------------------- /client/src/components/Dashboard/GuestDashBoard.tsx: -------------------------------------------------------------------------------- 1 | import dashboardIcon from "@/assets/dashbordIcon.png"; 2 | import plusIcon from "@/assets/plus.png"; 3 | import GuestNewMindMapModal from "@/components/Modal/GuestNewMindMapModal"; 4 | import LoginModal from "@/components/Modal/LoginModal"; 5 | import useModal from "@/hooks/useModal"; 6 | import { Button } from "@headlessui/react"; 7 | 8 | export default function GuestDashBoard() { 9 | const { closeModal: closeLoginModal, open: loginModal, openModal: openLoginModal } = useModal(); 10 | const { closeModal: closeConfirmModal, open: confirmModal, openModal: openConfirmModal } = useModal(); 11 | 12 | return ( 13 | <> 14 |
15 |

16 | 마인드맵을 만들고 17 |
18 | 브레인 스토밍에 활용해보세요 19 |

20 | 대쉬보드 아이콘 21 | 27 | { 31 | openLoginModal(); 32 | closeConfirmModal(); 33 | }} 34 | /> 35 | 41 |
42 | 43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /Docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | services: 2 | redis: 3 | image: redis:7.4.1-alpine 4 | container_name: redis 5 | networks: 6 | - my-network 7 | 8 | mysql: 9 | image: mysql:8.0.39 10 | container_name: mysql 11 | environment: 12 | - MYSQL_ROOT_PASSWORD=root 13 | - MYSQL_DATABASE=boomap 14 | - MYSQL_USER=boomap 15 | - MYSQL_PASSWORD=test 16 | command: 17 | - --character-set-server=utf8mb4 18 | - --collation-server=utf8mb4_unicode_ci 19 | - --skip-character-set-client-handshake 20 | networks: 21 | - my-network 22 | 23 | api-server: 24 | build: 25 | context: ./BE 26 | dockerfile: Dockerfile.api.dev 27 | container_name: api 28 | volumes: 29 | - ./BE:/app 30 | - /app/node_modules 31 | env_file: 32 | - .env.dev 33 | command: nest start api-server --watch 34 | networks: 35 | - my-network 36 | 37 | socket-server: 38 | build: 39 | context: ./BE 40 | dockerfile: Dockerfile.socket.dev 41 | container_name: socket 42 | volumes: 43 | - ./BE:/app 44 | - /app/node_modules 45 | env_file: 46 | - .env.dev 47 | command: nest start socket-server --watch 48 | networks: 49 | - my-network 50 | 51 | nginx: 52 | build: 53 | context: . 54 | dockerfile: Dockerfile.nginx.dev 55 | ports: 56 | - "80:80" 57 | - "443:443" 58 | container_name: nginx 59 | volumes: 60 | - ./nginx/dev.conf:/etc/nginx/conf.d/default.conf 61 | networks: 62 | - my-network 63 | 64 | fe: 65 | build: 66 | context: . 67 | dockerfile: Dockerfile.fe.dev 68 | container_name: fe 69 | volumes: 70 | - ./client/src:/app/src 71 | env_file: 72 | - .env.dev 73 | command: ["npm", "run", "dev"] 74 | networks: 75 | - my-network 76 | 77 | networks: 78 | my-network: 79 | driver: bridge 80 | -------------------------------------------------------------------------------- /client/src/konva_mindmap/events/deleteNode.ts: -------------------------------------------------------------------------------- 1 | import { NodeData } from "@/types/Node"; 2 | import { findRootNodeKey } from "../utils/findRootNodeKey"; 3 | import { useConnectionStore } from "@/store/useConnectionStore"; 4 | 5 | export function deleteNodes(data: string, selectedNodeIds: number | number[], overrideNodeData) { 6 | const newNodeData: NodeData = JSON.parse(data); 7 | const rootKey = findRootNodeKey(newNodeData); 8 | 9 | if (Array.isArray(selectedNodeIds) && selectedNodeIds.includes(rootKey)) { 10 | useConnectionStore.getState().propagateError("최상위 노드는 삭제되지 않습니다", "error"); 11 | return deleteNodes(data, [...newNodeData[rootKey].children], overrideNodeData); 12 | } 13 | if (!Array.isArray(selectedNodeIds) && selectedNodeIds === rootKey) { 14 | useConnectionStore.getState().propagateError("최상위 노드는 삭제되지 않습니다", "error"); 15 | return; 16 | } 17 | 18 | const nodeIds = Array.isArray(selectedNodeIds) ? selectedNodeIds : [selectedNodeIds]; 19 | if (nodeIds.some((id) => !id || !newNodeData[id])) return; 20 | 21 | function deleteNodeAndChildren(nodeId: number) { 22 | const node = newNodeData[nodeId]; 23 | if (!node) return; 24 | if (node.children) { 25 | [...node.children].forEach((childId) => { 26 | deleteNodeAndChildren(childId); 27 | }); 28 | } 29 | 30 | Object.values(newNodeData).forEach((node) => { 31 | if (node.children) { 32 | node.children = node.children.filter((id) => id !== nodeId); 33 | } 34 | }); 35 | 36 | delete newNodeData[nodeId]; 37 | } 38 | 39 | nodeIds.forEach((nodeId) => deleteNodeAndChildren(nodeId)); 40 | 41 | const handleSocketEvent = useConnectionStore.getState().handleSocketEvent; 42 | handleSocketEvent({ 43 | actionType: "updateNode", 44 | payload: newNodeData, 45 | callback: (response) => { 46 | if (response) { 47 | overrideNodeData(response); 48 | } 49 | }, 50 | }); 51 | } 52 | -------------------------------------------------------------------------------- /BE/apps/socket-server/src/guards/ws.jwt.auth.guard.ts: -------------------------------------------------------------------------------- 1 | import * as cookieParser from 'cookie-parser'; 2 | import { CanActivate, ExecutionContext, Injectable, Logger } from '@nestjs/common'; 3 | import { JsonWebTokenError, JwtService, TokenExpiredError } from '@nestjs/jwt'; 4 | import { InvalidTokenException } from '../exceptions'; 5 | import { Socket } from 'socket.io'; 6 | 7 | @Injectable() 8 | export class WsOptionalJwtGuard implements CanActivate { 9 | private readonly logger = new Logger(WsOptionalJwtGuard.name); 10 | constructor(private readonly jwtService: JwtService) {} 11 | 12 | canActivate(context: ExecutionContext): boolean { 13 | const client = context.switchToWs().getClient(); 14 | const token = client.handshake.auth.token; 15 | this.logger.log('토큰 : ' + token); 16 | 17 | if (!token) { 18 | return true; 19 | } 20 | 21 | try { 22 | this.jwtService.verify(token); 23 | const payload = this.jwtService.decode(token); 24 | const user = { id: payload['id'], email: payload['email'] }; 25 | client.data.user = user; 26 | return true; 27 | } catch (error) { 28 | if (error instanceof JsonWebTokenError) { 29 | throw new InvalidTokenException(); 30 | } else if (error instanceof TokenExpiredError) { 31 | try { 32 | const refreshToken = cookieParser.JSONCookie(client.handshake.headers.cookie)['refreshToken']; 33 | this.jwtService.verify(refreshToken); 34 | const payload = this.jwtService.decode(refreshToken); 35 | const user = { id: payload['id'], email: payload['email'] }; 36 | const accessToken = this.jwtService.sign(user, { expiresIn: '30m' }); 37 | client.emit('tokenRefresh', { accessToken }); 38 | client.data.user = user; 39 | return true; 40 | } catch { 41 | client.emit('tokenExpiredError', { message: '리프레시 토큰 만료' }); 42 | return false; 43 | } 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /client/src/components/Minutes/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useState } from "react"; 2 | import Tiptap from "./Tiptap"; 3 | 4 | export default function Minutes({ showMinutes, isAnimating, handleIsAnimating }) { 5 | const [width, setWidth] = useState(600); 6 | const startXRef = useRef(0); 7 | const startWidthRef = useRef(600); 8 | 9 | function handleMouseDown(e: React.MouseEvent) { 10 | e.preventDefault(); 11 | e.stopPropagation(); 12 | handleIsAnimating(); 13 | startXRef.current = e.clientX; 14 | startWidthRef.current = width; 15 | 16 | const handleMouseMove = (e: MouseEvent) => { 17 | const newWidth = Math.max(startWidthRef.current - (e.clientX - startXRef.current), 500); 18 | setWidth(newWidth); 19 | }; 20 | 21 | const handleMouseUp = () => { 22 | document.removeEventListener("mousemove", handleMouseMove); 23 | document.removeEventListener("mouseup", handleMouseUp); 24 | }; 25 | 26 | document.addEventListener("mousemove", handleMouseMove); 27 | document.addEventListener("mouseup", handleMouseUp); 28 | } 29 | 30 | const statusMinutes = showMinutes ? "translate-x-0 opacity-100" : "translate-x-full opacity-0"; 31 | const animation = isAnimating ? "transition-all duration-300 ease-in-out" : ""; 32 | 33 | return ( 34 | <> 35 |
39 |
40 |
41 |

회의록

42 | 43 |
44 |

편집은 마인드맵 소유자만 가능해요

45 |
46 |
47 |
48 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/modules/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { User, UserMindmapRole } from '@app/entity'; 4 | import { Repository } from 'typeorm'; 5 | import { UserCreateDto, UserInfoDto } from './dto'; 6 | 7 | @Injectable() 8 | export class UserService { 9 | constructor( 10 | @InjectRepository(User) private readonly userRepository: Repository, 11 | @InjectRepository(UserMindmapRole) private readonly userMindmapRoleRepository: Repository, 12 | ) {} 13 | 14 | async createGithubUser(user: UserCreateDto) { 15 | const createdUser = this.userRepository.create({ email: user.email, name: user.name, type: 'github' }); 16 | return await this.userRepository.save(createdUser); 17 | } 18 | 19 | async findByGithubEmail(email: string) { 20 | return await this.userRepository.findOne({ where: { email, type: 'github' } }); 21 | } 22 | 23 | async createKakaoUser(user: UserCreateDto) { 24 | const createdUser = this.userRepository.create({ email: user.email, name: user.name, type: 'kakao' }); 25 | return await this.userRepository.save(createdUser); 26 | } 27 | 28 | async findByKakaoEmail(email: string) { 29 | return await this.userRepository.findOne({ where: { email, type: 'kakao' } }); 30 | } 31 | 32 | async findById(id: number) { 33 | return await this.userRepository.findOne({ where: { id } }); 34 | } 35 | 36 | async getUserInfo(userId: number) { 37 | const user = await this.userRepository.findOne({ where: { id: userId } }); 38 | 39 | return { 40 | id: user.id, 41 | name: user.name, 42 | email: user.email, 43 | } as UserInfoDto; 44 | } 45 | 46 | async getRole(userId: number, mindmapId: number) { 47 | const userMindmapRole = await this.userMindmapRoleRepository.findOne({ 48 | where: { user: { id: userId }, mindmap: { id: mindmapId } }, 49 | }); 50 | 51 | return userMindmapRole?.role; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /BE/apps/socket-server/src/modules/subscriber/subscriber.service.ts: -------------------------------------------------------------------------------- 1 | import { RedisService } from '@liaoliaots/nestjs-redis'; 2 | import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; 3 | import Redis from 'ioredis'; 4 | import { MapGateway } from '../map/map.gateway'; 5 | import { NodeDto } from '../map/dto'; 6 | 7 | export interface RedisMessage { 8 | event: string; 9 | data: { 10 | connectionId: string; 11 | aiContent?: string; 12 | userId?: string; 13 | mindmapId?: string; 14 | nodeData?: Record; 15 | error?: string; 16 | }; 17 | } 18 | 19 | @Injectable() 20 | export class SubscriberService implements OnModuleInit { 21 | private readonly subscriberRedis: Redis; 22 | private readonly logger = new Logger(SubscriberService.name); 23 | 24 | constructor( 25 | private readonly redisService: RedisService, 26 | private readonly mapGateway: MapGateway, 27 | ) { 28 | this.subscriberRedis = redisService.getOrThrow('subscriber'); 29 | } 30 | 31 | onModuleInit() { 32 | this.subscribeToChannel(); 33 | } 34 | 35 | private subscribeToChannel() { 36 | this.subscriberRedis.subscribe('api-socket', (err) => { 37 | if (err) { 38 | this.logger.error('Redis subscribe error:', err); 39 | return; 40 | } 41 | }); 42 | 43 | this.subscriberRedis.on('message', this.handleMessage.bind(this)); 44 | } 45 | 46 | private async handleMessage(channel: string, message: string) { 47 | try { 48 | if (channel === 'api-socket') { 49 | const { event, data } = JSON.parse(message) as RedisMessage; 50 | if (event === 'textAiSocket') { 51 | this.textAiFinished(data); 52 | } 53 | } 54 | } catch (error) { 55 | this.logger.error('Redis handleMessage error:', error); 56 | } 57 | } 58 | 59 | textAiFinished(data) { 60 | if (data.error) { 61 | this.mapGateway.textAiError(data); 62 | return; 63 | } 64 | this.mapGateway.textAiResponse(data); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /client/src/components/Modal/ShareModal.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Input } from "@headlessui/react"; 2 | import Modal from "../common/Modal"; 3 | import { useState } from "react"; 4 | import { FiCopy, FiCheck } from "react-icons/fi"; 5 | 6 | interface ShareModalProps { 7 | open: boolean; 8 | closeModal: () => void; 9 | } 10 | 11 | export default function ShareModal({ open, closeModal }: ShareModalProps) { 12 | const [copySuccess, setCopySuccess] = useState(false); 13 | const currentUrl = window.location.href; 14 | 15 | async function copyLink() { 16 | try { 17 | await navigator.clipboard.writeText(currentUrl); 18 | setCopySuccess(true); 19 | } catch (error) { 20 | console.error("링크 복사 실패!!!"); 21 | } 22 | setTimeout(() => setCopySuccess(false), 2000); 23 | } 24 | 25 | function handleKeyDown(e: React.KeyboardEvent) { 26 | e.stopPropagation(); 27 | } 28 | 29 | return ( 30 | 31 |
32 |

협업 링크

33 | e.currentTarget.select()} 38 | onKeyDown={handleKeyDown} 39 | className="h-10 w-full truncate rounded-lg bg-grayscale-200 px-3 py-2 text-grayscale-500 focus:border-transparent focus:outline-none" 40 | /> 41 | 48 |

49 | 복사된 링크를 통해 팀원들과 브레인스토밍을 해보세요 50 |

51 |
52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /client/src/components/Modal/ProfileModal.tsx: -------------------------------------------------------------------------------- 1 | import { signOut } from "@/api/auth.api"; 2 | import { useConnectionStore } from "@/store/useConnectionStore"; 3 | import { Button } from "@headlessui/react"; 4 | import { useEffect, useRef } from "react"; 5 | import { FaUserCircle } from "react-icons/fa"; 6 | import { useNavigate } from "react-router-dom"; 7 | type ProfileModalProps = { 8 | open: boolean; 9 | close: () => void; 10 | }; 11 | export default function ProfileModal({ open, close }: ProfileModalProps) { 12 | const logout = useConnectionStore((state) => state.logout); 13 | const email = useConnectionStore((state) => state.email); 14 | const name = useConnectionStore((state) => state.name); 15 | const modalRef = useRef(null); 16 | const navigate = useNavigate(); 17 | 18 | const handleLogout = async () => { 19 | await signOut(); 20 | logout(); 21 | navigate("/"); 22 | close(); 23 | }; 24 | 25 | useEffect(() => { 26 | const handleClickOutside = (event: MouseEvent) => { 27 | if (modalRef.current && !modalRef.current.contains(event.target as Node)) { 28 | close(); 29 | } 30 | }; 31 | 32 | if (open) { 33 | document.addEventListener("click", handleClickOutside); 34 | } 35 | 36 | return () => { 37 | document.removeEventListener("click", handleClickOutside); 38 | }; 39 | }, [open, close]); 40 | 41 | return ( 42 | <> 43 | {open && ( 44 |
48 | 49 |
50 |

{name}

51 |

{email}

52 |
53 | 56 |
57 | )} 58 | 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /client/src/konva_mindmap/hooks/useAdjustedStage.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { NodeData } from "@/types/Node"; 3 | import { findRootNodeKey } from "../utils/findRootNodeKey"; 4 | 5 | export function useAdjustedStage(data: NodeData, containerWidth: number, containerHeight: number) { 6 | const [adjustedDimensions, setAdjustedDimensions] = useState({ scale: 1, x: 0, y: 0 }); 7 | const rootKey = findRootNodeKey(data); 8 | 9 | useEffect(() => { 10 | const bounds = calculateBounds(data, rootKey); 11 | const newDimensions = adjustStageToFit(bounds); 12 | setAdjustedDimensions(newDimensions); 13 | }, [containerWidth, containerHeight]); 14 | 15 | //그림이 그려지는 영역 크기 계산 16 | function calculateBounds(data: NodeData, rootId: number) { 17 | const stack = [data[rootId]]; 18 | let minX = Infinity, 19 | minY = Infinity, 20 | maxX = -Infinity, 21 | maxY = -Infinity; 22 | 23 | while (stack.length > 0) { 24 | const node = stack.pop(); 25 | if (!node || node.location.x === null || node.location.y === null) continue; 26 | 27 | minX = Math.min(minX, node.location.x); 28 | minY = Math.min(minY, node.location.y); 29 | maxX = Math.max(maxX, node.location.x); 30 | maxY = Math.max(maxY, node.location.y); 31 | 32 | node.children?.forEach((childId) => stack.push(data[childId])); 33 | } 34 | 35 | return { minX, minY, maxX, maxY }; 36 | } 37 | 38 | //그림 영역에 따른 canvas 크기 조정 39 | function adjustStageToFit(bounds: { minX: number; minY: number; maxX: number; maxY: number }) { 40 | const width = bounds.maxX - bounds.minX + 700; 41 | const height = bounds.maxY - bounds.minY + 700; 42 | 43 | const scaleX = containerWidth / width; 44 | const scaleY = containerHeight / height; 45 | let scale = Math.min(scaleX, scaleY); 46 | if (scale >= 1.5) scale = 1.5; 47 | else if (scale <= 0.25) scale = 0.25; 48 | 49 | return { 50 | scale, 51 | x: containerWidth / 2, 52 | y: containerHeight / 2, 53 | }; 54 | } 55 | 56 | return { adjustedDimensions, adjustStageToFit, calculateBounds }; 57 | } 58 | -------------------------------------------------------------------------------- /client/src/components/Modal/LoginModal.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@headlessui/react"; 2 | import gitHubIcon from "@/assets/github-mark-white.png"; 3 | import kakaoIcon from "@/assets/kakao_login.png"; 4 | import { createPortal } from "react-dom"; 5 | 6 | type LoginModalProps = { 7 | open: boolean; 8 | close: () => void; 9 | }; 10 | export default function LoginModal({ open, close }: LoginModalProps) { 11 | return ( 12 | <> 13 | {open && 14 | createPortal( 15 |
19 |
e.stopPropagation()} 22 | > 23 | 26 |

로그인

27 | 42 |
43 |
, 44 | document.body, 45 | )} 46 | 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /client/src/components/MindMapCanvas/CanvasButtons.tsx: -------------------------------------------------------------------------------- 1 | import ConfirmResetModal from "@/components/Modal/ConfirmResetModal"; 2 | import useModal from "@/hooks/useModal"; 3 | import { findRootNodeKey } from "@/konva_mindmap/utils/findRootNodeKey"; 4 | import { useNodeListContext } from "@/store/NodeListProvider"; 5 | import { useConnectionStore } from "@/store/useConnectionStore"; 6 | import { Button } from "@headlessui/react"; 7 | import { createPortal } from "react-dom"; 8 | 9 | export default function CanvasButtons({ handleReArrange, handleCenterMove, showMinutes, handleShowMinutes }) { 10 | const handleSocketEvent = useConnectionStore((state) => state.handleSocketEvent); 11 | const { data, overrideNodeData } = useNodeListContext(); 12 | const { open, openModal, closeModal } = useModal(); 13 | const rootKey = findRootNodeKey(data); 14 | const rootData = data[rootKey]; 15 | 16 | const resetAllNode = () => { 17 | handleSocketEvent({ 18 | actionType: "updateNode", 19 | payload: { 20 | [rootKey]: { ...rootData, children: [] }, 21 | }, 22 | callback: (response) => { 23 | overrideNodeData(response); 24 | closeModal(); 25 | }, 26 | }); 27 | }; 28 | 29 | function handleReArrangeAndMoveCenter() { 30 | handleReArrange(); 31 | handleCenterMove(); 32 | } 33 | 34 | return ( 35 |
36 | 42 | 48 | 54 | {createPortal(, document.body)} 55 |
56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /BE/apps/api-server/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareConsumer, Module } from '@nestjs/common'; 2 | import { ConfigModule, ConfigService } from '@nestjs/config'; 3 | import { WinstonModule } from 'nest-winston'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { RedisModule } from '@liaoliaots/nestjs-redis'; 6 | import { getWinstonConfig, getTypeOrmConfig, getRedisConfig, getJwtConfig } from '@app/config'; 7 | import { ConnectionModule } from './modules/connection/connection.module'; 8 | import { LoggerMiddleware } from './middlewares/logger.middleware'; 9 | import { AuthModule } from './modules/auth/auth.module'; 10 | import { UserModule } from './modules/user/user.module'; 11 | import { JwtModule } from '@nestjs/jwt'; 12 | import { join } from 'path'; 13 | import { MindmapModule } from './modules/mindmap/mindmap.module'; 14 | import { NodeModule } from './modules/node/node.module'; 15 | import { DashboardModule } from './modules/dashboard/dashboard.module'; 16 | import { AiModule } from './modules/ai/ai.module'; 17 | import { SubscriberModule } from './modules/subscriber/subscriber.module'; 18 | import { PublisherModule } from '@app/publisher'; 19 | 20 | @Module({ 21 | imports: [ 22 | ConfigModule.forRoot({ 23 | isGlobal: true, 24 | envFilePath: [join(__dirname, '..', '..', '..', '.env')], 25 | }), 26 | WinstonModule.forRootAsync({ 27 | inject: [ConfigService], 28 | useFactory: getWinstonConfig, 29 | }), 30 | RedisModule.forRootAsync( 31 | { 32 | inject: [ConfigService], 33 | useFactory: getRedisConfig, 34 | }, 35 | true, // isGlobal 36 | ), 37 | TypeOrmModule.forRootAsync({ 38 | inject: [ConfigService], 39 | useFactory: getTypeOrmConfig, 40 | }), 41 | JwtModule.registerAsync({ 42 | global: true, 43 | inject: [ConfigService], 44 | useFactory: getJwtConfig, 45 | }), 46 | ConnectionModule, 47 | UserModule, 48 | AuthModule, 49 | MindmapModule, 50 | NodeModule, 51 | DashboardModule, 52 | SubscriberModule, 53 | PublisherModule, 54 | AiModule, 55 | ], 56 | controllers: [], 57 | providers: [], 58 | exports: [], 59 | }) 60 | export class AppModule { 61 | configure(consumer: MiddlewareConsumer) { 62 | consumer.apply(LoggerMiddleware).forRoutes('*'); 63 | } 64 | } 65 | --------------------------------------------------------------------------------