Skip to content

Commit

Permalink
Merge pull request #250 from boostcampwm-2024/Feature/#71_로그인한_사용자는_권…
Browse files Browse the repository at this point in the history
…한이_있는_워크스페이스에_다른_사용자를_초대_구현

Feature/#71 로그인한 사용자는 권한이 있는 워크스페이스에 다른 사용자를 초대 구현
  • Loading branch information
github-actions[bot] authored Dec 2, 2024
2 parents 31b5d6f + 01fc4b5 commit c2eca98
Show file tree
Hide file tree
Showing 16 changed files with 450 additions and 86 deletions.
3 changes: 2 additions & 1 deletion @noctaCrdt/Interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,5 +195,6 @@ export interface WorkspaceListItem {
id: string;
name: string;
role: string;
memberCount?: number;
memberCount: number;
activeUsers: number;
}
20 changes: 4 additions & 16 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,26 +8,14 @@ import { useSocketStore } from "./stores/useSocketStore";
const App = () => {
// TODO 라우터, react query 설정
const { isErrorModalOpen, errorMessage } = useErrorStore();

const { userId } = useUserInfo();

useEffect(() => {
const socketStore = useSocketStore.getState();
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();
// };
// });
const savedWorkspace = sessionStorage.getItem("currentWorkspace");
const workspaceId = savedWorkspace ? JSON.parse(savedWorkspace).id : null;
console.log(workspaceId);
socketStore.init(userId, workspaceId);

return () => {
setTimeout(() => {
Expand Down
39 changes: 39 additions & 0 deletions client/src/components/modal/InviteModal.style.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// InviteModal.style.ts
import { css } from "@styled-system/css";

export const modalContentContainer = css({
display: "flex",
gap: "16px",
flexDirection: "column",
width: "400px",
padding: "16px",
});

export const titleText = css({
color: "gray.800",
fontSize: "xl",
fontWeight: "bold",
});

export const descriptionText = css({
color: "gray.600",
fontSize: "sm",
});

export const emailInput = css({
outline: "none",
border: "1px solid",
borderColor: "gray.200",
borderRadius: "md",
// 기본 input 스타일 추가
width: "100%",
padding: "8px 12px",
fontSize: "sm",
_placeholder: {
color: "gray.400",
},
_focus: {
borderColor: "blue.500",
boxShadow: "0 0 0 1px blue.500",
},
});
42 changes: 42 additions & 0 deletions client/src/components/modal/InviteModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// InviteModal.tsx
import { useState } from "react";
import { modalContentContainer, titleText, descriptionText, emailInput } from "./InviteModal.style";
import { Modal } from "./modal";

interface InviteModalProps {
isOpen: boolean;
onClose: () => void;
onInvite: (email: string) => void;
}

export const InviteModal = ({ isOpen, onClose, onInvite }: InviteModalProps) => {
const [email, setEmail] = useState("");

const handleInvite = () => {
onInvite(email);
setEmail("");
onClose();
};

return (
<Modal
isOpen={isOpen}
primaryButtonLabel="초대하기"
primaryButtonOnClick={handleInvite}
secondaryButtonLabel="취소"
secondaryButtonOnClick={onClose}
>
<div className={modalContentContainer}>
<h2 className={titleText}>워크스페이스 초대</h2>
<p className={descriptionText}>초대할 사용자의 이메일을 입력해주세요</p>
<input
className={emailInput}
onChange={(e) => setEmail(e.target.value)}
placeholder="이메일 주소 입력"
type="email"
value={email}
/>
</div>
</Modal>
);
};
64 changes: 59 additions & 5 deletions client/src/components/sidebar/components/menuButton/MenuButton.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { useState, useEffect } from "react";
import { InviteModal } from "@src/components/modal/InviteModal";
import { useModal } from "@src/components/modal/useModal";
import { useSocketStore } from "@src/stores/useSocketStore";
import { useToastStore } from "@src/stores/useToastStore";
import { useUserInfo } from "@stores/useUserStore";
import { menuItemWrapper, textBox, menuButtonContainer } from "./MenuButton.style";
import { MenuIcon } from "./components/MenuIcon";
Expand All @@ -7,28 +11,78 @@ import { WorkspaceSelectModal } from "./components/WorkspaceSelectModal";
export const MenuButton = () => {
const { name } = useUserInfo();
const [isOpen, setIsOpen] = useState(false);
const { socket, workspace } = useSocketStore();
const { addToast } = useToastStore();
const {
isOpen: isInviteModalOpen,
openModal: openInviteModal,
closeModal: closeInviteModal,
} = useModal();

const handleMenuClick = () => {
setIsOpen((prev) => !prev); // 토글 형태로 변경
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);
};
}, []);

useEffect(() => {
if (!socket) return;

// 초대 성공 응답 수신
socket.on(
"invite/workspace/success",
(data: { email: string; workspaceId: string; message: string }) => {
addToast(data.message);
closeInviteModal();
},
);

// 초대 실패 응답 수신
socket.on(
"invite/workspace/fail",
(data: { email: string; workspaceId: string; message: string }) => {
addToast(data.message);
closeInviteModal();
},
);

// 초대 받은 경우 수신
socket.on(
"workspace/invited",
(data: { workspaceId: string; invitedBy: string; message: string }) => {
addToast(data.message);
},
);

return () => {
socket.off("invite/workspace/success");
socket.off("invite/workspace/fail");
socket.off("workspace/invited");
};
}, [socket]);

const handleInvite = (email: string) => {
if (!socket || !workspace?.id) return;

socket.emit("invite/workspace", {
email,
workspaceId: workspace.id,
});
};
return (
<>
<button
className={`${menuButtonContainer} menu_button_container`}
onClick={handleMenuClick}
Expand All @@ -38,7 +92,7 @@ export const MenuButton = () => {
<MenuIcon />
<p className={textBox}>{name ?? "Nocta"}</p>
</button>
<WorkspaceSelectModal isOpen={isOpen} userName={name} />
</button>
<InviteModal isOpen={isInviteModalOpen} onClose={closeInviteModal} onInvite={handleInvite} />
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { glassContainer } from "@styled-system/recipes";

export const workspaceListContainer = css({
display: "flex",
gap: "sm",
gap: "8px",
flexDirection: "column",
padding: "md",
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,28 +1,49 @@
import { motion } from "framer-motion";
import { useRef } from "react";
import { useRef, useEffect, useState, useMemo } from "react";
import { SIDE_BAR } from "@constants/size";
import { useSocketStore } from "@src/stores/useSocketStore";
import {
workspaceListContainer,
workspaceModalContainer,
textBox,
} from "./WorkspaceSelectModal.style";
import { InviteButton } from "./components/InviteButton";
import { WorkspaceSelectItem } from "./components/WorkspaceSelectItem";

interface WorkspaceSelectModalProps {
isOpen: boolean;
userName: string | null;
onInviteClick: () => void;
}

export const WorkspaceSelectModal = ({ isOpen, userName }: WorkspaceSelectModalProps) => {
export const WorkspaceSelectModal = ({
isOpen,
userName,
onInviteClick,
}: WorkspaceSelectModalProps) => {
const modalRef = useRef<HTMLDivElement>(null);
const { availableWorkspaces } = useSocketStore(); // 소켓 스토어에서 직접 워크스페이스 목록 가져오기
const availableWorkspaces = useSocketStore((state) => state.availableWorkspaces);
const workspaceConnections = useSocketStore((state) => state.workspaceConnections);
const workspacesWithActiveUsers = useMemo(
() =>
availableWorkspaces.map((workspace) => ({
...workspace,
activeUsers: workspaceConnections[workspace.id] || 0,
})),
[availableWorkspaces, workspaceConnections],
);
const [workspaces, setWorkspaces] = useState(workspacesWithActiveUsers);

const informText = userName
? availableWorkspaces.length > 0
? ""
: "접속할 수 있는 워크스페이스가 없습니다."
: `다른 워크스페이스 기능은\n 회원전용 입니다`;

useEffect(() => {
setWorkspaces(workspacesWithActiveUsers);
}, [availableWorkspaces, workspacesWithActiveUsers]); // availableWorkspaces가 변경될 때마다 실행

return (
<motion.div
ref={modalRef}
Expand All @@ -44,10 +65,13 @@ export const WorkspaceSelectModal = ({ isOpen, userName }: WorkspaceSelectModalP
}}
>
<div className={workspaceListContainer}>
{userName && availableWorkspaces.length > 0 ? (
availableWorkspaces.map((workspace) => (
<WorkspaceSelectItem key={workspace.id} userName={userName} {...workspace} />
))
{userName && workspaces.length > 0 ? (
<>
{workspaces.map((workspace) => (
<WorkspaceSelectItem key={workspace.id} userName={userName} {...workspace} />
))}
<InviteButton onClick={onInviteClick} />
</>
) : (
<p className={textBox}>{informText}</p>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { css } from "@styled-system/css";

export const inviteButtonStyle = css({
display: "flex",
gap: "32px",
alignItems: "center",
borderTop: "1px solid",
borderColor: "gray.200",

width: "100%",
padding: "12px 16px",
color: "gray.600",
backgroundColor: "transparent",
transition: "all 0.2s",
cursor: "pointer",
_hover: {
backgroundColor: "gray.200",
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import Plus from "@assets/icons/plusIcon.svg?react";
import { inviteButtonStyle } from "./InviteButton.style";

interface InviteButtonProps {
onClick: () => void;
}

export const InviteButton = ({ onClick }: InviteButtonProps) => {
return (
<button onClick={onClick} className={inviteButtonStyle}>
<Plus />
<span>워크스페이스 초대하기</span>
</button>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,37 @@ export const itemContainer = css({
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "md",
borderLeft: "3px solid transparent", // 활성화되지 않았을 때 border 공간 확보
width: "100%",
padding: "8px 16px",
transition: "all 0.2s",
cursor: "pointer",
_hover: { backgroundColor: "gray.100" },
_hover: { backgroundColor: "gray.200" },
});

export const informBox = css({
display: "flex",
gap: "16px",
justifyContent: "center",
alignItems: "center",
marginLeft: "14px",
});
export const itemContent = css({
display: "flex",
gap: "2",
flex: 1,
gap: "10",
alignItems: "center",
});

export const activeItem = css({
borderLeft: "3px solid", // 왼쪽 하이라이트 바
borderLeftColor: "blue", // 포인트 컬러
backgroundColor: "rgba(0, 0, 0, 0.05)", // 약간 어두운 배경
_hover: {
backgroundColor: "rgba(0, 0, 0, 0.08)", // 호버 시 약간 더 어둡게
},
});

export const itemIcon = css({
display: "flex",
justifyContent: "center",
Expand All @@ -24,12 +44,13 @@ export const itemIcon = css({
width: "8",
height: "8",
fontSize: "sm",
backgroundColor: "gray.200",
backgroundColor: "gray.100",
});

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

export const itemName = css({
Expand Down
Loading

0 comments on commit c2eca98

Please sign in to comment.