Skip to content

Commit

Permalink
Merge pull request #238 from boostcampwm-2024/Feature/#219_워크스페이스_선택_…
Browse files Browse the repository at this point in the history
…모달창_구현

Feature/#219 워크스페이스 선택 모달창 구현
  • Loading branch information
github-actions[bot] authored Nov 30, 2024
2 parents d6e0494 + 4ebee79 commit 9184a34
Show file tree
Hide file tree
Showing 23 changed files with 784 additions and 231 deletions.
7 changes: 7 additions & 0 deletions @noctaCrdt/Interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ export interface RemotePageUpdateOperation {
}
export interface WorkSpaceSerializedProps {
id: string;
name: string;
pageList: Page[];
authUser: Map<string, string>;
}
Expand All @@ -190,3 +191,9 @@ export interface RemoteBlockReorderOperation {
client: number;
pageId: string;
}
export interface WorkspaceListItem {
id: string;
name: string;
role: string;
memberCount?: number;
}
12 changes: 8 additions & 4 deletions @noctaCrdt/WorkSpace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,29 @@ import { EditorCRDT } from "./Crdt";

export class WorkSpace {
id: string;
name: string;
pageList: Page[];
authUser: Map<string, string>;

constructor(id: string, pageList: Page[]) {
this.id = id;
this.pageList = pageList;
this.authUser = new Map();
constructor(id?: string, name?: string, pageList?: Page[], authUser?: Map<string, string>) {
this.id = id ? id : crypto.randomUUID();
this.name = name ? name : "Untitled";
this.pageList = pageList ? pageList : [];
this.authUser = authUser ? authUser : new Map();
}

serialize(): WorkSpaceSerializedProps {
return {
id: this.id,
name: this.name,
pageList: this.pageList,
authUser: this.authUser,
};
}

deserialize(data: WorkSpaceSerializedProps): void {
this.id = data.id;
this.name = data.name;
this.pageList = data.pageList.map((pageData) => {
const page = new Page();
page.deserialize(pageData);
Expand Down
23 changes: 20 additions & 3 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,33 @@ import { useSocketStore } from "./stores/useSocketStore";
const App = () => {
// TODO 라우터, react query 설정
const { isErrorModalOpen, errorMessage } = useErrorStore();
const { id } = useUserInfo();

const { userId } = useUserInfo();

useEffect(() => {
const socketStore = useSocketStore.getState();
socketStore.init(id);
socketStore.init(userId, null);

// // 소켓이 연결된 후에 이벤트 리스너 등록
// const { socket } = socketStore;
// socket?.on("connect", () => {
// const unsubscribe = socketStore.subscribeToWorkspaceOperations({
// onWorkspaceListUpdate: (workspaces) => {
// console.log("Workspace list updated:", workspaces);
// },
// });

// return () => {
// if (unsubscribe) unsubscribe();
// };
// });

return () => {
setTimeout(() => {
socketStore.cleanup();
}, 0);
};
}, [id]);
}, [userId]);

return (
<>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,29 @@ import { css } from "@styled-system/css";

export const menuItemWrapper = css({
display: "flex",
gap: "lg",
gap: "md",
alignItems: "center",
borderRightRadius: "md",
width: "300px",
width: "250px",
padding: "md",
boxShadow: "sm",
cursor: "pointer",
"&:hover": {
backgroundColor: "gray.100",
},
});

export const textBox = css({
textStyle: "display-medium20",
color: "gray.900",
color: "gray.700",
fontSize: "md",
});

export const menuDropdown = css({
zIndex: "dropdown",
position: "absolute",
top: "100%",
right: "0",
borderRadius: "md",
width: "100px",
marginTop: "sm",
boxShadow: "md",
export const menuButtonContainer = css({
position: "relative",
_before: {
position: "absolute",
top: "100%",
left: 0,
width: "100%",
height: "4px", // top: calc(100% + 4px)와 동일한 값
content: '""',
},
});
30 changes: 27 additions & 3 deletions client/src/components/sidebar/components/menuButton/MenuButton.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,40 @@
import { useState, useEffect } from "react";
import { useUserInfo } from "@stores/useUserStore";
import { menuItemWrapper, textBox } from "./MenuButton.style";
import { menuItemWrapper, textBox, menuButtonContainer } from "./MenuButton.style";
import { MenuIcon } from "./components/MenuIcon";
import { WorkspaceSelectModal } from "./components/WorkspaceSelectModal";

export const MenuButton = () => {
const { name } = useUserInfo();
const [isOpen, setIsOpen] = useState(false);

const handleMenuClick = () => {
setIsOpen((prev) => !prev); // 토글 형태로 변경
};

// 모달 외부 클릭시 닫기 처리를 위한 함수
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest(`.menu_button_container`)) {
setIsOpen(false);
}
};

// 외부 클릭 이벤트 리스너 등록
useEffect(() => {
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);

return (
<>
<button className={`${menuButtonContainer} menu_button_container`} onClick={handleMenuClick}>
<button className={menuItemWrapper}>
<MenuIcon />
<p className={textBox}>{name ?? "Nocta"}</p>
</button>
</>
<WorkspaceSelectModal isOpen={isOpen} userName={name} />
</button>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { css, cx } from "@styled-system/css";
import { glassContainer } from "@styled-system/recipes";

export const workspaceListContainer = css({
display: "flex",
gap: "sm",
flexDirection: "column",
padding: "md",
});

export const workspaceModalContainer = cx(
glassContainer({
border: "md",
borderRadius: "bottom",
background: "none",
boxShadow: "bottom",
}),
css({
display: "flex",
}),
);

export const textBox = css({
padding: "lg",
color: "gray.500",
textAlign: "center",
fontSize: "md",
whiteSpace: "pre-line",
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { motion } from "framer-motion";
import { useRef } from "react";
import { SIDE_BAR } from "@constants/size";
import { useSocketStore } from "@src/stores/useSocketStore";
import {
workspaceListContainer,
workspaceModalContainer,
textBox,
} from "./WorkspaceSelectModal.style";
import { WorkspaceSelectItem } from "./components/WorkspaceSelectItem";

interface WorkspaceSelectModalProps {
isOpen: boolean;
userName: string | null;
}

export const WorkspaceSelectModal = ({ isOpen, userName }: WorkspaceSelectModalProps) => {
const modalRef = useRef<HTMLDivElement>(null);
const { availableWorkspaces } = useSocketStore(); // 소켓 스토어에서 직접 워크스페이스 목록 가져오기

const informText = userName
? availableWorkspaces.length > 0
? ""
: "접속할 수 있는 워크스페이스가 없습니다."
: `다른 워크스페이스 기능은\n 회원전용 입니다`;
return (
<motion.div
ref={modalRef}
className={workspaceModalContainer}
initial={{ opacity: 0, y: -20 }}
animate={{
opacity: isOpen ? 1 : 0,
y: isOpen ? 0 : -20,
}}
transition={{ duration: 0.2 }}
style={{
position: "absolute",
top: "calc(100% + 4px)",
left: -1,
width: SIDE_BAR.WIDTH,
zIndex: 20,
pointerEvents: isOpen ? "auto" : "none",
display: isOpen ? "block" : "none",
}}
>
<div className={workspaceListContainer}>
{userName && availableWorkspaces.length > 0 ? (
availableWorkspaces.map((workspace) => (
<WorkspaceSelectItem key={workspace.id} userName={userName} {...workspace} />
))
) : (
<p className={textBox}>{informText}</p>
)}
</div>
</motion.div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// WorkspaceSelectItem.style.ts
import { css } from "@styled-system/css";

export const itemContainer = css({
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "md",
cursor: "pointer",
_hover: { backgroundColor: "gray.100" },
});

export const itemContent = css({
display: "flex",
gap: "2",
alignItems: "center",
});

export const itemIcon = css({
display: "flex",
justifyContent: "center",
alignItems: "center",
borderRadius: "xl",
width: "8",
height: "8",
fontSize: "sm",
backgroundColor: "gray.200",
});

export const itemInfo = css({
display: "flex",
flexDirection: "column",
});

export const itemName = css({
fontSize: "sm",
fontWeight: "medium",
});

export const itemRole = css({
color: "gray.500",
fontSize: "xs",
});

export const itemMemberCount = css({
color: "gray.500",
fontSize: "xs",
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { WorkspaceListItem } from "@noctaCrdt/Interfaces"; // 이전에 만든 인터페이스 import
import { useSocketStore } from "@src/stores/useSocketStore";
import { useUserInfo } from "@src/stores/useUserStore";
import {
itemContainer,
itemContent,
itemIcon,
itemInfo,
itemMemberCount,
itemName,
itemRole,
} from "./WorkspaceSelectItem.style";

interface WorkspaceSelectItemProps extends WorkspaceListItem {
userName: string;
}

export const WorkspaceSelectItem = ({ id, name, role, memberCount }: WorkspaceSelectItemProps) => {
const { userId } = useUserInfo();
const switchWorkspace = useSocketStore((state) => state.switchWorkspace);
const handleClick = () => {
switchWorkspace(userId, id);
};

return (
<button className={itemContainer} onClick={handleClick}>
<div className={itemContent}>
<div className={itemIcon}>{name.charAt(0)}</div>
<div className={itemInfo}>
<span className={itemName}>{name}</span>
<span className={itemMemberCount}>{role}</span>
</div>
</div>

{memberCount !== undefined && <span className={itemRole}>접속자수 : {memberCount}</span>}
</button>
);
};
6 changes: 5 additions & 1 deletion client/src/features/workSpace/WorkSpace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@ export const WorkSpace = () => {

useEffect(() => {
if (workspaceMetadata) {
const newWorkspace = new WorkSpaceClass(workspaceMetadata.id, workspaceMetadata.pageList);
const newWorkspace = new WorkSpaceClass(
workspaceMetadata.id,
workspaceMetadata.name,
workspaceMetadata.pageList,
);
newWorkspace.deserialize(workspaceMetadata);
setWorkspace(newWorkspace);

Expand Down
Loading

0 comments on commit 9184a34

Please sign in to comment.