diff --git a/@noctaCrdt/Crdt.ts b/@noctaCrdt/Crdt.ts index 5b0c125d..7f76129f 100644 --- a/@noctaCrdt/Crdt.ts +++ b/@noctaCrdt/Crdt.ts @@ -294,12 +294,12 @@ export class BlockCRDT extends CRDT { }); } - if (operation.color) { - newNode.color = operation.color; + if (operation.node.color) { + newNode.color = operation.node.color; } - if (operation.backgroundColor) { - newNode.backgroundColor = operation.backgroundColor; + if (operation.node.backgroundColor) { + newNode.backgroundColor = operation.node.backgroundColor; } this.LinkedList.insertById(newNode); diff --git a/@noctaCrdt/Interfaces.ts b/@noctaCrdt/Interfaces.ts index 63fba8a8..82659583 100644 --- a/@noctaCrdt/Interfaces.ts +++ b/@noctaCrdt/Interfaces.ts @@ -113,6 +113,13 @@ export interface RemoteBlockDeleteOperation { pageId: string; } +export interface RemoteBlockCheckboxOperation { + type: "blockCheckbox"; + blockId: BlockId; + isChecked: boolean; + pageId: string; +} + export interface RemoteCharDeleteOperation { type: "charDelete"; targetId: CharId; diff --git a/@noctaCrdt/Node.ts b/@noctaCrdt/Node.ts index d7220560..c0f6104e 100644 --- a/@noctaCrdt/Node.ts +++ b/@noctaCrdt/Node.ts @@ -51,6 +51,7 @@ export class Block extends Node { icon: string; crdt: BlockCRDT; listIndex?: number; + isChecked?: boolean; constructor(value: string, id: BlockId) { super(value, id); @@ -72,6 +73,7 @@ export class Block extends Node { icon: this.icon, crdt: this.crdt.serialize(), listIndex: this.listIndex ? this.listIndex : null, + isChecked: this.isChecked ? this.isChecked : null, }; } @@ -87,6 +89,7 @@ export class Block extends Node { block.icon = data.icon; block.crdt = BlockCRDT.deserialize(data.crdt); block.listIndex = data.listIndex ? data.listIndex : null; + block.isChecked = data.isChecked ? data.isChecked : null; return block; } } diff --git a/README.md b/README.md index 06e5af9d..b8062690 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,9 @@
- ![image](https://github.com/user-attachments/assets/dba641b3-417d-4bb6-9c87-4cfc78d8324c) + ![ezgif com-crop](https://github.com/user-attachments/assets/df92040b-a3fd-4bef-8b45-b5ad9e813fca) + +
@@ -102,7 +104,7 @@ Nocta에서 단순한 기록을 넘어, 새로운 글쓰기 경험을 시작하 ## 시스템 아키텍처 다이어그램 -![image](https://github.com/user-attachments/assets/ab96462b-5f38-4dd9-9c72-984829fa873d) +![제목 없음-2024-07-18-1129_2](https://github.com/user-attachments/assets/91c6477b-4acd-4dd5-bc93-9e204347bc10) ## 📅 프로젝트 기간 diff --git a/client/src/apis/auth.ts b/client/src/apis/auth.ts index cb7887de..bce93c0d 100644 --- a/client/src/apis/auth.ts +++ b/client/src/apis/auth.ts @@ -1,4 +1,5 @@ import { useMutation, useQuery } from "@tanstack/react-query"; +import { AxiosError } from "axios"; import { useUserActions } from "@stores/useUserStore"; import { unAuthorizationFetch, fetch } from "./axios"; @@ -6,8 +7,15 @@ const authKey = { all: ["auth"] as const, refresh: () => [...authKey.all, "refresh"] as const, }; +export interface ApiErrorResponse { + message: string; + code?: string; +} -export const useSignupMutation = (onSuccess: () => void) => { +interface MutationOptions { + onError?: (error: AxiosError) => void; +} +export const useSignupMutation = (onSuccess: () => void, options?: MutationOptions) => { const fetcher = ({ name, email, password }: { name: string; email: string; password: string }) => unAuthorizationFetch.post("/auth/register", { name, email, password }); @@ -16,10 +24,10 @@ export const useSignupMutation = (onSuccess: () => void) => { onSuccess: () => { onSuccess(); }, + onError: options?.onError, }); }; - -export const useLoginMutation = (onSuccess: () => void) => { +export const useLoginMutation = (onSuccess: () => void, options?: MutationOptions) => { const { setUserInfo } = useUserActions(); const fetcher = ({ email, password }: { email: string; password: string }) => @@ -33,6 +41,7 @@ export const useLoginMutation = (onSuccess: () => void) => { setUserInfo(id, name, accessToken); onSuccess(); }, + onError: options?.onError, }); }; diff --git a/client/src/assets/icons/pencil.svg b/client/src/assets/icons/pencil.svg new file mode 100644 index 00000000..df93841c --- /dev/null +++ b/client/src/assets/icons/pencil.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/client/src/components/inputField/InputField.style.ts b/client/src/components/inputField/InputField.style.ts index d9e8b1cc..5efeb341 100644 --- a/client/src/components/inputField/InputField.style.ts +++ b/client/src/components/inputField/InputField.style.ts @@ -6,6 +6,7 @@ export const formGroup = css({ export const inputContainer = css({ position: "relative", + border: "1px solid white", borderRadius: "md", padding: "1", background: "white/30", diff --git a/client/src/components/inputField/InputField.tsx b/client/src/components/inputField/InputField.tsx index 96ba23b7..2b112ca3 100644 --- a/client/src/components/inputField/InputField.tsx +++ b/client/src/components/inputField/InputField.tsx @@ -7,11 +7,25 @@ interface InputFieldProps { onChange: (e: React.ChangeEvent) => void; placeholder?: string; Icon?: React.FunctionComponent>; + isError?: boolean; } -export const InputField = ({ type, name, value, onChange, placeholder, Icon }: InputFieldProps) => ( +export const InputField = ({ + type, + name, + value, + onChange, + placeholder, + Icon, + isError, +}: InputFieldProps) => (
-
+
- {Icon && } + {Icon && }
); diff --git a/client/src/components/modal/RenameModal.style.ts b/client/src/components/modal/RenameModal.style.ts new file mode 100644 index 00000000..0965effc --- /dev/null +++ b/client/src/components/modal/RenameModal.style.ts @@ -0,0 +1,29 @@ +import { css } from "@styled-system/css"; + +export const container = css({ + display: "flex", + gap: "4", + flexDirection: "column", +}); + +export const title = css({ + color: "gray.700", + fontSize: "lg", + fontWeight: "medium", +}); + +export const input = css({ + borderColor: "gray.200", + borderRadius: "md", + borderWidth: "1px", + width: "full", + paddingY: "2", + paddingX: "3", + _focus: { + outline: "none", + borderColor: "blue.500", + }, + _hover: { + borderColor: "gray.300", + }, +}); diff --git a/client/src/components/modal/RenameModal.tsx b/client/src/components/modal/RenameModal.tsx new file mode 100644 index 00000000..5834e982 --- /dev/null +++ b/client/src/components/modal/RenameModal.tsx @@ -0,0 +1,41 @@ +import { useState } from "react"; +import { container, title, input } from "./RenameModal.style"; +import { Modal } from "./modal"; + +interface RenameModalProps { + isOpen: boolean; + onClose: () => void; + onRename: (newName: string) => void; + currentName: string; +} + +export const RenameModal = ({ isOpen, onClose, onRename, currentName }: RenameModalProps) => { + const [name, setName] = useState(currentName); + + const handleRename = () => { + if (name.trim()) { + onRename(name); + onClose(); + } + }; + + return ( + +
+

워크스페이스 이름 변경

+ setName(e.target.value)} + placeholder="새로운 워크스페이스 이름" + /> +
+
+ ); +}; diff --git a/client/src/components/sidebar/components/menuButton/MenuButton.style.ts b/client/src/components/sidebar/components/menuButton/MenuButton.style.ts index fbe93299..a4b8efc5 100644 --- a/client/src/components/sidebar/components/menuButton/MenuButton.style.ts +++ b/client/src/components/sidebar/components/menuButton/MenuButton.style.ts @@ -2,7 +2,7 @@ import { css } from "@styled-system/css"; export const menuItemWrapper = css({ display: "flex", - gap: "md", + gap: "32px", alignItems: "center", width: "250px", padding: "md", @@ -15,6 +15,7 @@ export const menuItemWrapper = css({ export const textBox = css({ color: "gray.700", fontSize: "md", + fontWeight: "medium", }); export const menuButtonContainer = css({ @@ -24,7 +25,48 @@ export const menuButtonContainer = css({ top: "100%", left: 0, width: "100%", - height: "4px", // top: calc(100% + 4px)와 동일한 값 + height: "4px", content: '""', }, }); + +export const nameWrapper = css({ + display: "flex", + gap: "1", + flexDirection: "column", + borderColor: "gray.200", + borderRadius: "md", + borderWidth: "1px", + padding: "sm", + borderStyle: "solid", + _hover: { + borderColor: "gray.300", // hover 시 테두리 색상 변경 + }, +}); +export const workspaceInfo = css({ + display: "flex", + gap: "0.5", + flexDirection: "column", +}); + +export const workspaceHeader = css({ + display: "flex", + gap: "2", + alignItems: "center", +}); + +export const currentWorkspaceNameBox = css({ + color: "gray.600", + fontSize: "sm", + fontWeight: "medium", +}); + +export const workspaceRole = css({ + color: "gray.500", + fontSize: "xs", +}); + +export const userCount = css({ + color: "gray.500", + fontSize: "xs", +}); diff --git a/client/src/components/sidebar/components/menuButton/MenuButton.tsx b/client/src/components/sidebar/components/menuButton/MenuButton.tsx index 8522ca59..5601b707 100644 --- a/client/src/components/sidebar/components/menuButton/MenuButton.tsx +++ b/client/src/components/sidebar/components/menuButton/MenuButton.tsx @@ -5,7 +5,16 @@ import { useSocketStore } from "@src/stores/useSocketStore"; import { useToastStore } from "@src/stores/useToastStore"; import { useWorkspaceStore } from "@src/stores/useWorkspaceStore"; import { useUserInfo } from "@stores/useUserStore"; -import { menuItemWrapper, textBox, menuButtonContainer } from "./MenuButton.style"; +import { + menuItemWrapper, + textBox, + menuButtonContainer, + // userCount, + // currentWorkspaceNameBox, + // workspaceInfo, + // workspaceRole, + // nameWrapper, +} from "./MenuButton.style"; import { MenuIcon } from "./components/MenuIcon"; import { WorkspaceSelectModal } from "./components/WorkspaceSelectModal"; @@ -14,12 +23,15 @@ export const MenuButton = () => { const [isOpen, setIsOpen] = useState(false); const { socket, workspace } = useSocketStore(); const { addToast } = useToastStore(); + const currentRole = useWorkspaceStore((state) => state.currentRole); + // const currentWorkspaceName = useWorkspaceStore((state) => state.currentWorkspaceName); + // const currentActiveUsers = useWorkspaceStore((state) => state.currentActiveUsers); + // const currentMemberCount = useWorkspaceStore((state) => state.currentMemberCount); const { isOpen: isInviteModalOpen, openModal: openInviteModal, closeModal: closeInviteModal, } = useModal(); - const currentRole = useWorkspaceStore((state) => state.currentRole); const handleMenuClick = () => { setIsOpen((prev) => !prev); }; @@ -91,6 +103,7 @@ export const MenuButton = () => { } openInviteModal(); }; + return ( <> state.setCurrentRole); + const [isRenameModalOpen, setIsRenameModalOpen] = useState(false); + const { socket } = useSocketStore(); + const setCurrentWorkspaceName = useWorkspaceStore((state) => state.setCurrentWorkspaceName); const isActive = workspace?.id === id; // 현재 워크스페이스 확인 + const isOwner = role === "owner"; const handleClick = () => { if (!isActive) { switchWorkspace(userId, id); setCurrentRole(role); + setCurrentWorkspaceName(name); addToast(`워크스페이스(${name})에 접속하였습니다.`); } }; + const handleRename = (newName: string) => { + socket?.emit("workspace/rename", { + workspaceId: id, + newName, + }); + }; return ( - + )}
- - + + + setIsRenameModalOpen(false)} + onRename={handleRename} + currentName={name} + /> + ); }; diff --git a/client/src/features/auth/AuthButton.tsx b/client/src/features/auth/AuthButton.tsx index 8a3aadae..3f500edb 100644 --- a/client/src/features/auth/AuthButton.tsx +++ b/client/src/features/auth/AuthButton.tsx @@ -3,8 +3,8 @@ import { TextButton } from "@components/button/textButton"; import { Modal } from "@components/modal/modal"; import { useModal } from "@components/modal/useModal"; import { useCheckLogin } from "@stores/useUserStore"; -import { AuthModal } from "./AuthModal"; import { container } from "./AuthButton.style"; +import { AuthModal } from "./AuthModal"; export const AuthButton = () => { const isLogin = useCheckLogin(); diff --git a/client/src/features/auth/AuthModal.style.ts b/client/src/features/auth/AuthModal.style.ts index a0f56136..09d82cd6 100644 --- a/client/src/features/auth/AuthModal.style.ts +++ b/client/src/features/auth/AuthModal.style.ts @@ -16,6 +16,13 @@ export const title = css({ textShadow: "0 2px 4px rgba(0,0,0,0.1)", }); +export const errorWrapper = css({ + display: "flex", + justifyContent: "center", + width: "100%", + height: "20px", + paddingBottom: "40px", +}); export const toggleButton = css({ marginBottom: "md", color: "white", @@ -24,7 +31,13 @@ export const toggleButton = css({ textDecoration: "underline", }, }); - +export const errorContainer = css({ + display: "flex", + position: "relative", + alignContent: "center", + alignItems: "center", + color: "red", +}); export const formContainer = css({ display: "flex", gap: "md", diff --git a/client/src/features/auth/AuthModal.tsx b/client/src/features/auth/AuthModal.tsx index b7d07d57..4d770780 100644 --- a/client/src/features/auth/AuthModal.tsx +++ b/client/src/features/auth/AuthModal.tsx @@ -1,12 +1,20 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { useLoginMutation, useSignupMutation } from "@apis/auth"; -import { useState } from "react"; +import { AxiosError } from "axios"; +import { useState, useEffect } from "react"; +import { useLoginMutation, useSignupMutation, ApiErrorResponse } from "@apis/auth"; import Lock from "@assets/icons/lock.svg?react"; import Mail from "@assets/icons/mail.svg?react"; import User from "@assets/icons/user.svg?react"; import { InputField } from "@components/inputField/InputField"; import { Modal } from "@components/modal/modal"; -import { container, formContainer, title, toggleButton } from "./AuthModal.style"; +import { + container, + formContainer, + title, + toggleButton, + errorContainer, + errorWrapper, +} from "./AuthModal.style"; interface AuthModalProps { isOpen: boolean; @@ -20,13 +28,67 @@ export const AuthModal = ({ isOpen, onClose }: AuthModalProps) => { email: "", password: "", }); + const [error, setError] = useState(""); - const { mutate: login } = useLoginMutation(onClose); - const { mutate: signUp } = useSignupMutation(() => login(formData)); + const getErrorMessage = (error: AxiosError) => { + switch (error.response?.status) { + case 400: + return "입력하신 정보가 올바르지 않습니다."; + case 401: + return "이메일 또는 비밀번호가 올바르지 않습니다."; + case 409: + return "이미 사용 중인 이메일입니다."; + default: + return "오류가 발생했습니다. 다시 시도해주세요."; + } + }; + + const { mutate: login } = useLoginMutation(onClose, { + onError: (error: AxiosError) => { + setError(getErrorMessage(error)); + }, + }); + + const { mutate: signUp } = useSignupMutation( + () => { + // 회원가입 성공 시 자동으로 로그인 시도 + const { email, password } = formData; + login({ email, password }); + }, + { + onError: (error: AxiosError) => { + setError(getErrorMessage(error)); + }, + }, + ); + + const validateForm = (): boolean => { + // 이메일 유효성 검사 + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(formData.email)) { + setError("올바른 이메일 형식이 아닙니다."); + return false; + } + + // 비밀번호 유효성 검사 (최소 8자) + if (formData.password.length < 1) { + setError("비밀번호를 입력해주세요."); + return false; + } + + // 회원가입 시 이름 필드 검사 + if (mode === "register" && !formData.name.trim()) { + setError("이름을 입력해주세요."); + return false; + } + + return true; + }; const toggleMode = () => { setMode(mode === "login" ? "register" : "login"); setFormData({ email: "", password: "", name: "" }); + setError(""); }; const closeModal = () => { @@ -41,6 +103,9 @@ export const AuthModal = ({ isOpen, onClose }: AuthModalProps) => { }; const handleSubmitButtonClick = () => { + if (!validateForm()) { + return; + } if (mode === "register") { signUp(formData); } else { @@ -48,6 +113,32 @@ export const AuthModal = ({ isOpen, onClose }: AuthModalProps) => { } }; + const getFieldError = (fieldName: string): boolean => { + if (!error) return false; + if (error === "올바른 이메일 형식이 아닙니다." && fieldName === "email") { + return true; + } + if (error === "비밀번호를 입력해주세요." && fieldName === "password") { + return true; + } + if (error === "이름을 입력해주세요." && fieldName === "name") { + return true; + } + return false; + }; + + useEffect(() => { + if (isOpen) { + // 모달이 열릴 때마다 초기화 + setFormData({ + name: "", + email: "", + password: "", + }); + setError(""); + setMode("login"); + } + }, [isOpen]); // isOpen이 변경될 때마다 실행 return ( { onChange={handleInputChange} placeholder="이름" Icon={User} + isError={getFieldError("name")} /> )} { onChange={handleInputChange} placeholder="이메일" Icon={Mail} + isError={getFieldError("email")} /> { onChange={handleInputChange} placeholder="비밀번호" Icon={Lock} + isError={getFieldError("password")} /> - +
{error &&

{error}

}
{steps.map((_, index) => (
diff --git a/client/src/stores/useSocketStore.ts b/client/src/stores/useSocketStore.ts index b5720f98..e48e2278 100644 --- a/client/src/stores/useSocketStore.ts +++ b/client/src/stores/useSocketStore.ts @@ -6,6 +6,7 @@ import { RemoteCharDeleteOperation, RemoteBlockUpdateOperation, RemoteBlockReorderOperation, + RemoteBlockCheckboxOperation, RemoteCharUpdateOperation, RemotePageDeleteOperation, RemotePageUpdateOperation, @@ -15,6 +16,7 @@ import { } from "@noctaCrdt/Interfaces"; import { io, Socket } from "socket.io-client"; import { create } from "zustand"; +import { useToastStore } from "./useToastStore"; import { useWorkspaceStore } from "./useWorkspaceStore"; class BatchProcessor { @@ -77,6 +79,7 @@ interface SocketStore { subscribeToPageOperations: (handlers: PageOperationsHandlers) => (() => void) | undefined; setWorkspace: (workspace: WorkSpaceSerializedProps) => void; sendOperation: (operation: any) => void; + sendBlockCheckboxOperation: (operation: RemoteBlockCheckboxOperation) => void; } interface RemoteOperationHandlers { @@ -89,6 +92,7 @@ interface RemoteOperationHandlers { onRemoteCharUpdate: (operation: RemoteCharUpdateOperation) => void; onRemoteCursor: (position: CursorPosition) => void; onBatchOperations: (batch: any[]) => void; + onRemoteBlockCheckbox: (operation: RemoteBlockCheckboxOperation) => void; } interface PageOperationsHandlers { @@ -140,6 +144,20 @@ export const useSocketStore = create((set, get) => ({ setWorkspace(workspace); }); + socket.on( + "workspace/user/left", + (data: { workspaceId: string; userName: string; message: string }) => { + useToastStore.getState().addToast(data.message); + }, + ); + + socket.on( + "workspace/user/join", + (data: { workspaceId: string; userName: string; message: string }) => { + useToastStore.getState().addToast(data.message); + }, + ); + socket.on("workspace/connections", (connections: Record) => { set({ workspaceConnections: connections }); }); @@ -160,6 +178,9 @@ export const useSocketStore = create((set, get) => ({ const currentWorkspace = availableWorkspaces.find((ws) => ws.id === workspace!.id); if (currentWorkspace) { useWorkspaceStore.getState().setCurrentRole(currentWorkspace.role); + useWorkspaceStore.getState().setCurrentWorkspaceName(currentWorkspace.name); + useWorkspaceStore.getState().setCurrentActiveUsers(currentWorkspace.activeUsers); + useWorkspaceStore.getState().setCurrentMemberCount(currentWorkspace.memberCount); } }); @@ -189,7 +210,7 @@ export const useSocketStore = create((set, get) => ({ // 기존 연결 정리 if (socket) { if (workspace?.id) { - socket.emit("leave/workspace", { workspaceId: workspace.id }); + socket.emit("leave/workspace", { workspaceId: workspace.id, userId }); } socket.disconnect(); } @@ -221,45 +242,45 @@ export const useSocketStore = create((set, get) => ({ }, sendBlockInsertOperation: (operation: RemoteBlockInsertOperation) => { - const { socket } = get(); - socket?.emit("insert/block", operation); - // const { sendOperation } = get(); - // sendOperation(operation); + // const { socket } = get(); + // socket?.emit("insert/block", operation); + const { sendOperation } = get(); + sendOperation(operation); }, sendCharInsertOperation: (operation: RemoteCharInsertOperation) => { - const { socket } = get(); - socket?.emit("insert/char", operation); - // const { sendOperation } = get(); - // sendOperation(operation); + // const { socket } = get(); + // socket?.emit("insert/char", operation); + const { sendOperation } = get(); + sendOperation(operation); }, sendBlockUpdateOperation: (operation: RemoteBlockUpdateOperation) => { - const { socket } = get(); - socket?.emit("update/block", operation); - // const { sendOperation } = get(); - // sendOperation(operation); + // const { socket } = get(); + // socket?.emit("update/block", operation); + const { sendOperation } = get(); + sendOperation(operation); }, sendBlockDeleteOperation: (operation: RemoteBlockDeleteOperation) => { - const { socket } = get(); - socket?.emit("delete/block", operation); - // const { sendOperation } = get(); - // sendOperation(operation); + // const { socket } = get(); + // socket?.emit("delete/block", operation); + const { sendOperation } = get(); + sendOperation(operation); }, sendCharDeleteOperation: (operation: RemoteCharDeleteOperation) => { - const { socket } = get(); - socket?.emit("delete/char", operation); - // const { sendOperation } = get(); - // sendOperation(operation); + // const { socket } = get(); + // socket?.emit("delete/char", operation); + const { sendOperation } = get(); + sendOperation(operation); }, sendCharUpdateOperation: (operation: RemoteCharUpdateOperation) => { - const { socket } = get(); - socket?.emit("update/char", operation); - // const { sendOperation } = get(); - // sendOperation(operation); + // const { socket } = get(); + // socket?.emit("update/char", operation); + const { sendOperation } = get(); + sendOperation(operation); }, sendCursorPosition: (position: CursorPosition) => { @@ -268,10 +289,15 @@ export const useSocketStore = create((set, get) => ({ }, sendBlockReorderOperation: (operation: RemoteBlockReorderOperation) => { + // const { socket } = get(); + // socket?.emit("reorder/block", operation); + const { sendOperation } = get(); + sendOperation(operation); + }, + + sendBlockCheckboxOperation: (operation: RemoteBlockCheckboxOperation) => { const { socket } = get(); - socket?.emit("reorder/block", operation); - // const { sendOperation } = get(); - // sendOperation(operation); + socket?.emit("checkbox/block", operation); }, subscribeToRemoteOperations: (handlers: RemoteOperationHandlers) => { @@ -287,6 +313,7 @@ export const useSocketStore = create((set, get) => ({ socket.on("update/char", handlers.onRemoteCharUpdate); socket.on("cursor", handlers.onRemoteCursor); socket.on("batch/operations", handlers.onBatchOperations); + socket.on("checkbox/block", handlers.onRemoteBlockCheckbox); return () => { socket.off("update/block", handlers.onRemoteBlockUpdate); @@ -298,6 +325,7 @@ export const useSocketStore = create((set, get) => ({ socket.off("update/char", handlers.onRemoteCharUpdate); socket.off("cursor", handlers.onRemoteCursor); socket.off("batch/operations", handlers.onBatchOperations); + socket.off("checkbox/block", handlers.onRemoteBlockCheckbox); }; }, diff --git a/client/src/stores/useWorkspaceStore.ts b/client/src/stores/useWorkspaceStore.ts index 4cd1d1bb..41376e54 100644 --- a/client/src/stores/useWorkspaceStore.ts +++ b/client/src/stores/useWorkspaceStore.ts @@ -5,11 +5,22 @@ import { create } from "zustand"; interface WorkspaceStore { // 현재 선택된 워크스페이스의 권한 currentRole: string | null; - // 권한 설정 함수 + currentWorkspaceName: string | null; + currentActiveUsers: number | null; + currentMemberCount: number | null; setCurrentRole: (role: string | null) => void; + setCurrentWorkspaceName: (name: string | null) => void; + setCurrentActiveUsers: (count: number | null) => void; + setCurrentMemberCount: (count: number | null) => void; } export const useWorkspaceStore = create((set) => ({ currentRole: null, setCurrentRole: (role) => set({ currentRole: role }), + currentWorkspaceName: null, + setCurrentWorkspaceName: (name) => set({ currentWorkspaceName: name }), + currentActiveUsers: null, + setCurrentActiveUsers: (count) => set({ currentActiveUsers: count }), + currentMemberCount: null, + setCurrentMemberCount: (count) => set({ currentMemberCount: count }), })); diff --git a/server/src/workspace/workspace.gateway.ts b/server/src/workspace/workspace.gateway.ts index b8351f6e..89e2e7f1 100644 --- a/server/src/workspace/workspace.gateway.ts +++ b/server/src/workspace/workspace.gateway.ts @@ -20,6 +20,7 @@ import { RemoteBlockUpdateOperation, RemotePageCreateOperation, RemoteBlockReorderOperation, + RemoteBlockCheckboxOperation, RemoteCharUpdateOperation, CursorPosition, } from "@noctaCrdt/Interfaces"; @@ -142,6 +143,18 @@ export class WorkspaceGateway implements OnGatewayInit, OnGatewayConnection, OnG ).serialize(); client.emit("workspace", currentWorkSpace); + if (user && NewWorkspaceId !== "guest") { + const server = this.workSpaceService.getServer(); + server + .to(NewWorkspaceId) + .except(client.id) + .emit("workspace/user/join", { + workspaceId: NewWorkspaceId, + userName: user.name, + message: `${user.name}님이 워크스페이스에 참가하셨습니다.`, + }); + } + const assignedId = (this.clientIdCounter += 1); const clientInfo: ClientInfo = { clientId: assignedId, @@ -168,7 +181,6 @@ export class WorkspaceGateway implements OnGatewayInit, OnGatewayConnection, OnG this.SocketStoreBroadcastWorkspaceConnections(); }, 100); - client.broadcast.emit("userJoined", { clientId: assignedId }); this.logger.log(`클라이언트 연결 성공 - User ID: ${userId}, Client ID: ${assignedId}`); this.logger.debug(`현재 연결된 클라이언트 수: ${this.clientMap.size}`); } catch (error) { @@ -207,11 +219,18 @@ export class WorkspaceGateway implements OnGatewayInit, OnGatewayConnection, OnG Copy; // workspace.gateway.ts @SubscribeMessage("leave/workspace") async handleWorkspaceLeave( - @MessageBody() data: { workspaceId: string }, + @MessageBody() data: { workspaceId: string; userId: string }, @ConnectedSocket() client: Socket, ): Promise { try { client.leave(data.workspaceId); + const user = await this.authService.findById(data.userId); + const server = this.workSpaceService.getServer(); + server.to(data.workspaceId).emit("workspace/user/left", { + workspaceId: data.workspaceId, + userName: user.name, + message: `${user.name}님이 워크스페이스에서 퇴장하셨습니다.`, + }); this.SocketStoreBroadcastWorkspaceConnections(); } catch (error) { this.logger.error(`워크스페이스 퇴장 중 오류 발생`, error.stack); @@ -299,6 +318,38 @@ export class WorkspaceGateway implements OnGatewayInit, OnGatewayConnection, OnG } } + @SubscribeMessage("workspace/rename") + async handleWorkspaceRename( + @MessageBody() data: { workspaceId: string; newName: string }, + @ConnectedSocket() client: Socket, + ): Promise { + try { + const workspace = await this.workSpaceService.getWorkspace(data.workspaceId); + + const userRole = await this.workSpaceService.getUserRole( + client.data.userId, + data.workspaceId, + ); + if (userRole !== "owner") { + throw new WsException("권한이 없습니다."); + } + + // 워크스페이스 이름 업데이트 + await this.workSpaceService.updateWorkspaceName(data.workspaceId, data.newName); + + // 여기에 업데이트된 workspace/list정보 날리는 메소드 구현해줘 .. + const members = await this.workSpaceService.getWorkspaceMembers(data.workspaceId); + // 워크스페이스 멤버들에게 변경 알림 + const server = this.workSpaceService.getServer(); + for (const memberId of members) { + const memberWorkspaces = await this.workSpaceService.getUserWorkspaces(memberId); + server.to(`user:${memberId}`).emit("workspace/list", memberWorkspaces); + } + } catch (error) { + throw new WsException(`워크스페이스 이름 변경 실패: ${error.message}`); + } + } + /** * 페이지 참여 처리 * 클라이언트가 특정 페이지에 참여할 때 호출됨 @@ -582,6 +633,7 @@ export class WorkspaceGateway implements OnGatewayInit, OnGatewayConnection, OnG pageId: data.pageId, } as RemoteBlockDeleteOperation; this.emitOperation(client.id, data.pageId, "delete/block", operation, batch); + currentPage.crdt.LinkedList.updateAllOrderedListIndices(); } catch (error) { this.logger.error( `Block Delete 연산 처리 중 오류 발생 - Client ID: ${clientInfo?.clientId}`, @@ -606,14 +658,14 @@ export class WorkspaceGateway implements OnGatewayInit, OnGatewayConnection, OnG `Block Update 연산 수신 - Client ID: ${clientInfo?.clientId}, Data:`, JSON.stringify(data), ); - + console.log(data); const { workspaceId } = client.data; const currentPage = await this.workSpaceService.getPage(workspaceId, data.pageId); if (!currentPage) { throw new Error(`Page with id ${data.pageId} not found`); } currentPage.crdt.remoteUpdate(data.node, data.pageId); - + currentPage.crdt.LinkedList.updateAllOrderedListIndices(); const operation = { type: "blockUpdate", node: data.node, @@ -660,6 +712,7 @@ export class WorkspaceGateway implements OnGatewayInit, OnGatewayConnection, OnG pageId: data.pageId, } as RemoteBlockReorderOperation; this.emitOperation(client.id, data.pageId, "reorder/block", operation, batch); + currentPage.crdt.LinkedList.updateAllOrderedListIndices(); } catch (error) { this.logger.error( `Block Reorder 연산 처리 중 오류 발생 - Client ID: ${clientInfo?.clientId}`, @@ -669,6 +722,50 @@ export class WorkspaceGateway implements OnGatewayInit, OnGatewayConnection, OnG } } + /** + * 블록 Checkbox 연산 처리 + */ + @SubscribeMessage("checkbox/block") + async handleBlockCheckbox( + @MessageBody() data: RemoteBlockCheckboxOperation, + @ConnectedSocket() client: Socket, + ): Promise { + const clientInfo = this.clientMap.get(client.id); + try { + this.logger.debug( + `Block checkbox 연산 수신 - Client ID: ${clientInfo?.clientId}, Data:`, + JSON.stringify(data), + ); + const { workspaceId } = client.data; + const currentBlock = await this.workSpaceService.getBlock( + workspaceId, + data.pageId, + data.blockId, + ); + + if (!currentBlock) { + throw new Error(`Block with id ${data.blockId} not found`); + } + + currentBlock.isChecked = data.isChecked; + + const operation = { + type: "blockCheckbox", + blockId: data.blockId, + pageId: data.pageId, + isChecked: data.isChecked, + }; + + client.broadcast.to(data.pageId).emit("checkbox/block", operation); + } catch (error) { + this.logger.error( + `Block Checkbox 연산 처리 중 오류 발생 - Client ID: ${clientInfo?.clientId}`, + error.stack, + ); + throw new WsException(`Checkbox 연산 실패: ${error.message}`); + } + } + /** * 글자 삽입 연산 처리 */ diff --git a/server/src/workspace/workspace.service.ts b/server/src/workspace/workspace.service.ts index 507d24a9..d749b146 100644 --- a/server/src/workspace/workspace.service.ts +++ b/server/src/workspace/workspace.service.ts @@ -128,7 +128,59 @@ export class WorkSpaceService implements OnModuleInit { } return page.crdt.LinkedList.nodeMap[JSON.stringify(blockId)]; } + async getUserRole(userId: string, workspaceId: string): Promise { + const workspaces = await this.getUserWorkspaces(userId); + const workspace = workspaces.find((ws) => ws.id === workspaceId); + if (!workspace) { + throw new Error("Workspace not found or user not a member"); + } + return workspace.role; + } + async updateWorkspaceName(workspaceId: string, newName: string): Promise { + try { + // 메모리에서 워크스페이스 찾기 + const workspace = await this.getWorkspace(workspaceId); + if (!workspace) { + throw new Error(`Workspace with id ${workspaceId} not found`); + } + + // 메모리상의 워크스페이스 이름 업데이트 + workspace.name = newName; + + // MongoDB 업데이트 + const result = await this.workspaceModel.findOneAndUpdate( + { id: workspaceId }, + { $set: { name: newName } }, + { new: true }, + ); + + if (!result) { + throw new Error(`Failed to update workspace name in database`); + } + this.logger.log(`Workspace ${workspaceId} name updated to: ${newName}`); + } catch (error) { + this.logger.error(`Failed to update workspace name: ${error.message}`); + throw error; + } + } + + async getWorkspaceMembers(workspaceId: string): Promise { + try { + // 워크스페이스 데이터를 DB에서 조회 + const workspaceData = await this.workspaceModel.findOne({ id: workspaceId }); + if (!workspaceData) { + throw new Error(`Workspace with id ${workspaceId} not found`); + } + + // authUser Map에서 모든 유저 ID를 배열로 변환하여 반환 + // authUser는 Map 형태로 userId와 role을 저장하고 있음 + return Array.from(workspaceData.authUser.keys()); + } catch (error) { + this.logger.error(`Failed to get workspace members: ${error.message}`); + throw error; + } + } // 워크스페이스 생성 async createWorkspace(userId: string, name: string): Promise { const newWorkspace = await this.workspaceModel.create({