diff --git a/@noctaCrdt/Interfaces.ts b/@noctaCrdt/Interfaces.ts index 82659583..46d5d2c7 100644 --- a/@noctaCrdt/Interfaces.ts +++ b/@noctaCrdt/Interfaces.ts @@ -15,7 +15,15 @@ export type ElementType = | "blockquote" | "hr"; -export type AnimationType = "none" | "highlight" | "gradation"; +export type AnimationType = + | "none" + | "highlight" + | "rainbow" + | "fadeIn" + | "slideIn" + | "pulse" + | "gradation" + | "bounce"; export type TextStyleType = "bold" | "italic" | "underline" | "strikethrough"; diff --git a/client/index.html b/client/index.html index c12f6480..b2b4e07a 100644 --- a/client/index.html +++ b/client/index.html @@ -1,5 +1,5 @@ - + @@ -14,19 +14,13 @@ - + - + diff --git a/client/src/constants/option.ts b/client/src/constants/option.ts index 0cc4875e..d9181d48 100644 --- a/client/src/constants/option.ts +++ b/client/src/constants/option.ts @@ -22,7 +22,12 @@ export const OPTION_CATEGORIES = { options: [ { id: "none", label: "없음" }, { id: "highlight", label: "하이라이트" }, + { id: "rainbow", label: "레인보우" }, { id: "gradation", label: "그라데이션" }, + { id: "fadeIn", label: "페이드 인" }, + { id: "slideIn", label: "슬라이드 인" }, + { id: "pulse", label: "펄스" }, + { id: "bounce", label: "바운스" }, ] as { id: AnimationType; label: string }[], }, DUPLICATE: { diff --git a/client/src/features/editor/components/IconBlock/IconBlock.style.ts b/client/src/features/editor/components/IconBlock/IconBlock.style.ts index 2a95384b..f48ef8cf 100644 --- a/client/src/features/editor/components/IconBlock/IconBlock.style.ts +++ b/client/src/features/editor/components/IconBlock/IconBlock.style.ts @@ -4,8 +4,8 @@ import { css, cva } from "@styled-system/css"; export const iconContainerStyle = css({ display: "flex", justifyContent: "center", - alignItems: "center", - minWidth: "24px", + width: "24px", + height: "24px", marginRight: "8px", }); diff --git a/client/src/features/editor/components/MenuBlock/MenuBlock.style.ts b/client/src/features/editor/components/MenuBlock/MenuBlock.style.ts index 23635f7b..1e730654 100644 --- a/client/src/features/editor/components/MenuBlock/MenuBlock.style.ts +++ b/client/src/features/editor/components/MenuBlock/MenuBlock.style.ts @@ -8,8 +8,8 @@ export const menuBlockStyle = css({ left: 0, justifyContent: "center", alignItems: "center", - width: "20px", - height: "20px", + width: "24px", + height: "24px", marginLeft: "-20px", opacity: 0, transition: "opacity 0.2s ease-in-out", diff --git a/client/src/features/editor/components/block/Block.animation.ts b/client/src/features/editor/components/block/Block.animation.ts index a02b8e6a..ab80523e 100644 --- a/client/src/features/editor/components/block/Block.animation.ts +++ b/client/src/features/editor/components/block/Block.animation.ts @@ -1,28 +1,40 @@ +const defaultState = { + background: "transparent", + opacity: 1, + x: 0, + y: 0, + scale: 1, + backgroundSize: "100% 100%", + backgroundPosition: "0 0", +}; + const none = { initial: { - background: "transparent", + ...defaultState, }, animate: { - background: "transparent", + ...defaultState, }, }; const highlight = { initial: { - background: `linear-gradient(to right, - #BFBFFF70 0%, - #BFBFFF70 0%, - transparent 0%, - transparent 100% - )`, + ...defaultState, + background: `linear-gradient(to right, + #BFBFFF70 0%, + #BFBFFF70 0%, + transparent 0%, + transparent 100% + )`, }, animate: { - background: `linear-gradient(to right, - #BFBFFF70 0%, - #BFBFFF70 100%, - transparent 100%, - transparent 100% - )`, + ...defaultState, + background: `linear-gradient(to right, + #BFBFFF70 0%, + #BFBFFF70 100%, + transparent 100%, + transparent 100% + )`, transition: { duration: 1, ease: "linear", @@ -30,17 +42,19 @@ const highlight = { }, }; -const gradation = { +const rainbow = { initial: { + ...defaultState, background: `linear-gradient(to right, - #BFBFFF 0%, - #B0E2FF 50%, - #FFE4E1 100% + #BFBFFF 0%, + #B0E2FF 50%, + #FFE4E1 100% )`, backgroundSize: "300% 100%", backgroundPosition: "100% 0", }, animate: { + ...defaultState, background: [ `linear-gradient(to right, #BFBFFF 0%, @@ -63,6 +77,7 @@ const gradation = { #FFE4E1 100% )`, ], + backgroundSize: "300% 100%", transition: { duration: 3, ease: "linear", @@ -72,8 +87,110 @@ const gradation = { }, }; +const fadeIn = { + initial: { + ...defaultState, + opacity: 0, + }, + animate: { + ...defaultState, + opacity: 1, + transition: { + duration: 1, + ease: "easeOut", + }, + }, +}; + +const slideIn = { + initial: { + ...defaultState, + x: -20, + opacity: 0, + }, + animate: { + ...defaultState, + x: 0, + opacity: 1, + transition: { + duration: 0.5, + ease: "easeOut", + }, + }, +}; + +const pulse = { + initial: { + ...defaultState, + scale: 1, + }, + animate: { + ...defaultState, + scale: [1, 1.02, 1], + transition: { + duration: 1.5, + ease: "easeInOut", + repeat: Infinity, + }, + }, +}; + +const gradation = { + initial: { + ...defaultState, + background: `linear-gradient( + 90deg, + rgba(255,255,255,0) 0%, + #BFBFFF80 70%, + rgba(255,255,255,0) 100% + )`, + backgroundSize: "200% 100%", + backgroundPosition: "100% 0", + }, + animate: { + ...defaultState, + background: `linear-gradient( + 90deg, + rgba(255,255,255,0) 0%, + #BFBFFF80 70%, + rgba(255,255,255,0) 100% + )`, + backgroundSize: "200% 100%", + backgroundPosition: ["100% 0", "-100% 0"], + transition: { + duration: 2, + ease: "linear", + repeat: Infinity, + }, + }, +}; + +const bounce = { + initial: { + ...defaultState, + y: 0, + }, + animate: { + ...defaultState, + y: [-2, 2, -2], + transition: { + duration: 1, + ease: "easeInOut", + repeat: Infinity, + }, + }, +}; + export const blockAnimation = { none, highlight, + rainbow, + fadeIn, + slideIn, + pulse, gradation, + bounce, }; + +// types.ts +export type AnimationType = keyof typeof blockAnimation; diff --git a/client/src/features/editor/components/block/Block.style.ts b/client/src/features/editor/components/block/Block.style.ts index 89e9dc3e..ee9b14af 100644 --- a/client/src/features/editor/components/block/Block.style.ts +++ b/client/src/features/editor/components/block/Block.style.ts @@ -38,8 +38,9 @@ export const contentWrapperStyle = cva({ position: "relative", flex: 1, flexDirection: "row", - alignItems: "center", + alignItems: "flex-start", width: "100%", + height: "100%", }, }); diff --git a/server/src/auth/auth.controller.ts b/server/src/auth/auth.controller.ts index 623d15b6..7ce2427a 100644 --- a/server/src/auth/auth.controller.ts +++ b/server/src/auth/auth.controller.ts @@ -103,9 +103,6 @@ export class AuthController { // DB에서 refresh token 삭제 await this.authService.removeRefreshToken(user.id); - // 사용자의 token version 증가 - await this.authService.increaseTokenVersion(user); - // 쿠키 삭제 this.authService.clearCookie(req.res); } diff --git a/server/src/auth/auth.service.ts b/server/src/auth/auth.service.ts index 88faa464..262a5619 100644 --- a/server/src/auth/auth.service.ts +++ b/server/src/auth/auth.service.ts @@ -71,7 +71,6 @@ export class AuthService { return this.jwtService.sign({ sub: user.id, email: user.email, - tokenVersion: await this.increaseTokenVersion(user), }); } @@ -87,12 +86,6 @@ export class AuthService { return refreshToken; } - async increaseTokenVersion(user: User): Promise { - const tokenVersion = user.tokenVersion + 1; - await this.userModel.updateOne({ id: user.id }, { tokenVersion }); - return tokenVersion; - } - async login(user: User, res: Response): Promise { const accessToken = await this.generateAccessToken(user); const refreshToken = await this.generateRefreshToken(user.id); diff --git a/server/src/auth/guards/jwt-auth.guard.ts b/server/src/auth/guards/jwt-auth.guard.ts index 961db914..6a16526d 100644 --- a/server/src/auth/guards/jwt-auth.guard.ts +++ b/server/src/auth/guards/jwt-auth.guard.ts @@ -1,14 +1,9 @@ import { Injectable, ExecutionContext, UnauthorizedException } from "@nestjs/common"; import { AuthGuard } from "@nestjs/passport"; -import { AuthService } from "../auth.service"; -import { JwtService } from "@nestjs/jwt"; @Injectable() export class JwtAuthGuard extends AuthGuard("jwt") { - constructor( - private readonly authService: AuthService, - private readonly jwtService: JwtService, - ) { + constructor() { super(); } @@ -22,14 +17,6 @@ export class JwtAuthGuard extends AuthGuard("jwt") { const canActivate = (await super.canActivate(context)) as boolean; - // Access Token의 tokenVersion과 사용자의 tokenVersion 일치 여부 확인 - const decodedToken = this.jwtService.decode(token) as { sub: string; tokenVersion: number }; - const user = await this.authService.findById(decodedToken.sub); - - if (!user || user.tokenVersion !== decodedToken.tokenVersion) { - throw new UnauthorizedException("Invalid token version"); - } - return canActivate; } } diff --git a/server/src/auth/schemas/user.schema.ts b/server/src/auth/schemas/user.schema.ts index 41ebadf9..1881429f 100644 --- a/server/src/auth/schemas/user.schema.ts +++ b/server/src/auth/schemas/user.schema.ts @@ -18,9 +18,6 @@ export class User { @Prop({ required: true }) name: string; - @Prop({ required: true, default: () => 0 }) - tokenVersion: number; - @Prop() refreshToken: string; diff --git a/server/src/auth/test/auth.controller.spec.ts b/server/src/auth/test/auth.controller.spec.ts index 2c386554..617f4831 100644 --- a/server/src/auth/test/auth.controller.spec.ts +++ b/server/src/auth/test/auth.controller.spec.ts @@ -25,7 +25,6 @@ describe("AuthController", () => { validateUser: jest.fn(), getProfile: jest.fn(), refresh: jest.fn(), - increaseTokenVersion: jest.fn(), isValidEmail: jest.fn(), }; diff --git a/server/src/auth/test/auth.service.spec.ts b/server/src/auth/test/auth.service.spec.ts index bb17b6f8..0e609d4d 100644 --- a/server/src/auth/test/auth.service.spec.ts +++ b/server/src/auth/test/auth.service.spec.ts @@ -25,7 +25,6 @@ describe("AuthService", () => { email: "test@example.com", password: "hashedPassword", name: "Test User", - tokenVersion: 0, }; const mockUserModel = { @@ -139,7 +138,6 @@ describe("AuthService", () => { id: "mockNanoId123", email: "test@example.com", name: "Test User", - tokenVersion: 0, }; const mockResponse = { @@ -152,7 +150,6 @@ describe("AuthService", () => { expect(jwtService.sign).toHaveBeenCalledWith({ sub: user.id, email: user.email, - tokenVersion: user.tokenVersion + 1, }); expect(mockResponse.cookie).toHaveBeenCalledWith("refreshToken", expect.any(String), { httpOnly: true, @@ -234,7 +231,6 @@ describe("AuthService", () => { expect(jwtService.sign).toHaveBeenCalledWith({ sub: mockUser.id, email: mockUser.email, - tokenVersion: 1, }); expect(mockResponse.header).toHaveBeenCalledWith("Authorization", `Bearer test-token`); expect(result).toEqual({ diff --git a/server/src/workspace/schemas/workspace.schema.ts b/server/src/workspace/schemas/workspace.schema.ts index 1b9852ce..a6f5c7a8 100644 --- a/server/src/workspace/schemas/workspace.schema.ts +++ b/server/src/workspace/schemas/workspace.schema.ts @@ -214,7 +214,7 @@ export class Workspace { @Prop({ type: [Page], default: [] }) pageList: Page[]; - @Prop({ type: Map, default: new Map() }) + @Prop({ type: Object, default: {} }) authUser: Map; @Prop({ default: Date.now }) diff --git a/server/src/workspace/workspace.gateway.ts b/server/src/workspace/workspace.gateway.ts index 89e2e7f1..53834d2b 100644 --- a/server/src/workspace/workspace.gateway.ts +++ b/server/src/workspace/workspace.gateway.ts @@ -116,14 +116,13 @@ export class WorkspaceGateway implements OnGatewayInit, OnGatewayConnection, OnG const workspaces = await this.workSpaceService.getUserWorkspaces(userId); const userInfo = await this.authService.getProfile(userId); let NewWorkspaceId = ""; - if (userId === "guest") { client.join("guest"); NewWorkspaceId = "guest"; } else if (workspaces.length === 0) { const workspace = await this.workSpaceService.createWorkspace( userId, - `${userInfo.name}의 Workspace`, + `${userInfo === null ? "guest" : userInfo.name}의 Workspace`, ); client.join(workspace.id); NewWorkspaceId = workspace.id; @@ -176,7 +175,7 @@ export class WorkspaceGateway implements OnGatewayInit, OnGatewayConnection, OnG }; }); - this.logger.log(`Sending workspace list to client ${client.id}`); + this.logger.log(`Sending workspace list to client(user.id): ${client.data.userId}`); client.emit("workspace/list", workspaceList); this.SocketStoreBroadcastWorkspaceConnections(); }, 100); @@ -454,6 +453,7 @@ export class WorkspaceGateway implements OnGatewayInit, OnGatewayConnection, OnG page: newPage.serialize(), } as RemotePageCreateOperation; client.emit("create/page", operation); + this.emitOperation(client.id, workspaceId, "create/page", operation, batch); } catch (error) { this.logger.error( @@ -658,7 +658,6 @@ 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) { diff --git a/server/src/workspace/workspace.service.ts b/server/src/workspace/workspace.service.ts index d749b146..8405f99a 100644 --- a/server/src/workspace/workspace.service.ts +++ b/server/src/workspace/workspace.service.ts @@ -57,23 +57,28 @@ export class WorkSpaceService implements OnModuleInit { // room의 연결된 클라이언트 수 확인 const room = this.server.sockets.adapter.rooms.get(roomId); const clientCount = room ? room.size : 0; - // 연결된 클라이언트가 없으면 DB에 저장하고 메모리에서 제거 if (clientCount === 0) { - const serializedWorkspace = workspace.serialize(); + const serializedData = workspace.serialize(); + // 스키마에 맞게 데이터 변환 + const workspaceData = { + id: roomId, + name: workspace.name, + pageList: serializedData.pageList, + authUser: serializedData.authUser, + updatedAt: new Date(), + }; bulkOps.push({ updateOne: { filter: { id: roomId }, - update: { $set: { ...serializedWorkspace } }, + update: { $set: workspaceData }, upsert: true, }, }); - this.workspaces.delete(roomId); this.logger.log(`Workspace ${roomId} will be saved to DB and removed from memory`); } } - // DB에 저장할 작업이 있으면 한 번에 실행 if (bulkOps.length > 0) { await this.workspaceModel.bulkWrite(bulkOps, { ordered: false }); @@ -97,6 +102,9 @@ export class WorkSpaceService implements OnModuleInit { // DB에서 찾기 const workspaceJSON = await this.workspaceModel.findOne({ id: workspaceId }); + if (!workspaceJSON) { + throw new Error(`workspaceJson ${workspaceId} not found`); + } const workspace = new CRDTWorkSpace(); if (workspaceJSON) { @@ -172,10 +180,11 @@ export class WorkSpaceService implements OnModuleInit { if (!workspaceData) { throw new Error(`Workspace with id ${workspaceId} not found`); } - // authUser Map에서 모든 유저 ID를 배열로 변환하여 반환 // authUser는 Map 형태로 userId와 role을 저장하고 있음 - return Array.from(workspaceData.authUser.keys()); + // return Array.from(workspaceData.authUser.keys()); + const members = await this.userModel.find({ workspaces: workspaceId }).select("id"); + return members.map((member) => member.id); } catch (error) { this.logger.error(`Failed to get workspace members: ${error.message}`); throw error; @@ -185,9 +194,9 @@ export class WorkSpaceService implements OnModuleInit { async createWorkspace(userId: string, name: string): Promise { const newWorkspace = await this.workspaceModel.create({ name, - authUser: new Map([[userId, "owner"]]), // 올바른 형태 + authUser: { [userId]: "owner" }, }); - + // newWorkspace.authUser[userId] // 유저 정보 업데이트 await this.userModel.updateOne({ id: userId }, { $push: { workspaces: newWorkspace.id } }); @@ -237,22 +246,28 @@ export class WorkSpaceService implements OnModuleInit { const workspaces = await this.workspaceModel.find({ id: { $in: user.workspaces }, }); - - const workspaceList = workspaces.map((workspace) => { - const room = this.getServer().sockets.adapter.rooms.get(workspace.id); - return { - id: workspace.id, - name: workspace.name, - role: workspace.authUser.get(userId) || "editor", - memberCount: workspace.authUser.size, - activeUsers: room ? room.size : 0, - }; - }); - + const workspaceList = await Promise.all( + workspaces.map(async (workspace) => { + const room = this.getServer().sockets.adapter.rooms.get(workspace.id); + const role = workspace.authUser[userId] || "editor"; + + // users 컬렉션에서 멤버 수 조회 + const memberCount = await this.userModel.countDocuments({ + workspaces: workspace.id, + }); + + return { + id: workspace.id, + name: workspace.name, + role, + memberCount, + activeUsers: room?.size || 0, + }; + }), + ); return workspaceList; } - // 워크스페이스에 유저 초대 async inviteUserToWorkspace( ownerId: string, workspaceId: string, @@ -264,18 +279,22 @@ export class WorkSpaceService implements OnModuleInit { throw new Error(`Workspace with id ${workspaceId} not found`); } - // 권한 확인 - if (!workspace.authUser.has(ownerId) || workspace.authUser.get(ownerId) !== "owner") { + // 권한 확인 - 객체의 속성 접근 방식으로 변경 + if (!(ownerId in workspace.authUser) || workspace.authUser[ownerId] !== "owner") { throw new Error(`User ${ownerId} does not have permission to invite users to this workspace`); } - // 워크스페이스에 유저 추가 - if (!workspace.authUser.has(invitedUserId)) { - workspace.authUser.set(invitedUserId, "editor"); + // 워크스페이스에 유저 추가 - 객체 속성 할당 방식으로 변경 + if (!(invitedUserId in workspace.authUser)) { + // 일반 객체 업데이트 + workspace.authUser[invitedUserId] = "editor"; await workspace.save(); // 유저 정보 업데이트 - await this.userModel.updateOne({ id: invitedUserId }, { $push: { workspaces: workspaceId } }); + await this.userModel.updateOne( + { id: invitedUserId }, + { $addToSet: { workspaces: workspaceId } }, + ); } } }