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 } },
+ );
}
}
}