Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/assets/icon/workspace/check.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/assets/icon/workspace/mail.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/assets/icon/workspace/trash.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/assets/icon/workspace/user.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 3 additions & 3 deletions src/components/common/controlbox/ControlBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export default function ControlBox({
return (
<div
className={twMerge(
"flex items-center justify-between min-w-180 px-7 py-6 border-[0.5px] rounded-component-lg",
"flex w-full min-w-0 items-center justify-between gap-6 px-7 py-6 border-[0.5px] rounded-component-lg",
"border-chart-3 bg-chart-3/7",
containerClassName,
className,
Expand All @@ -51,12 +51,12 @@ export default function ControlBox({
>
<div
className={twMerge(
"flex items-center gap-4 sm:gap-8",
"flex min-w-0 items-center gap-4 sm:gap-8",
contentClassName,
)}
>
{leadingSlot ? <div className="shrink-0">{leadingSlot}</div> : null}
<div className="flex flex-col gap-2">
<div className="flex min-w-0 flex-col gap-2">
<h3 className={twMerge("font-heading3 text-chart-3", titleClassName)}>
{title}
</h3>
Expand Down
44 changes: 44 additions & 0 deletions src/components/common/toggle/Toggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { twMerge } from "tailwind-merge";

type TToggleProps = {
checked: boolean;
onToggle: () => void;
ariaLabel: string;
disabled?: boolean;
className?: string;
thumbClassName?: string;
};

export default function Toggle({
checked,
onToggle,
ariaLabel,
disabled = false,
className,
thumbClassName,
}: TToggleProps) {
return (
<button
type="button"
role="switch"
aria-label={ariaLabel}
aria-checked={checked}
disabled={disabled}
Comment thread
jjjsun marked this conversation as resolved.
Resolved
onClick={onToggle}
className={twMerge(
"relative inline-flex h-8 w-15 items-center rounded-full transition-colors",
checked ? "bg-chart-3" : "bg-gray-200",
disabled && "cursor-not-allowed opacity-50",
className,
)}
>
<span
className={twMerge(
"inline-block h-6 w-6 transform rounded-full bg-white shadow-sm transition-transform",
checked ? "translate-x-7.5" : "translate-x-1.5",
thumbClassName,
)}
/>
</button>
);
}
53 changes: 53 additions & 0 deletions src/components/workspace/MemberItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type {
TMemberRole,
TWorkspaceMember,
} from "@/types/workspace/workspace";

import MemberRoleSelect from "./MemberRoleSelect";

import MailIcon from "@/assets/icon/workspace/mail.svg?react";
import TrashIcon from "@/assets/icon/workspace/trash.svg?react";
import UserIcon from "@/assets/icon/workspace/user.svg?react";

type TProps = {
member: TWorkspaceMember;
onRoleChange: (newRole: TMemberRole) => void;
};

export default function MemberItem({ member, onRoleChange }: TProps) {
return (
<li className="flex items-center justify-between py-5 gap-4">
<div className="flex items-center gap-4">
<div className="flex bg-text-placeholder/30 h-12 w-12 items-center justify-center shrink-0 rounded-component-lg overflow-hidden">
{member.profileImageUrl ? (
<img
src={member.profileImageUrl}
alt={`${member.name} 프로필 이미지`}
className="w-full h-full object-cover"
/>
) : (
<UserIcon className="text-text-auth-sub h-6 w-6" />
)}
</div>
<div className="min-w-0">
<p className="truncate text-text-main font-body1">{member.name}</p>
<div className=" flex text-text-auth-sub items-center gap-2">
<MailIcon className="w-4 h-4" />
<p className="truncate">{member.email}</p>
</div>
</div>
</div>
<div className="flex items-center gap-4">
<MemberRoleSelect role={member.role} onChange={onRoleChange} />
<button
type="button"
aria-label="멤버 삭제버튼"
onClick={() => alert("TODO:팀원삭제확인모달버튼")}
className="text-text-sub hover:text-text-main transition-all"
>
<TrashIcon className="w-5 h-5" />
</button>
</div>
</li>
);
}
92 changes: 92 additions & 0 deletions src/components/workspace/MemberList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { useState } from "react";

import type {
TMemberRole,
TWorkspaceMember,
} from "@/types/workspace/workspace";

import MemberItem from "./MemberItem";
import Button from "../common/button/Button";

import PlusIcon from "@/assets/icon/workspace/plus.svg?react";

const mockMembers: TWorkspaceMember[] = [
{
name: "김택연",
email: "himnaera@naver.com",
profileImageUrl: null,
role: "ADMIN",
},
{
name: "문보경",
email: "parkbogum@naver.com",
profileImageUrl: null,
role: "ADMIN",
},
{
name: "노경은",
email: "grandpapa@naver.com",
profileImageUrl: null,
role: "MEMBER",
},
{
name: "조병현",
email: "niceguy@naver.com",
profileImageUrl: null,
role: "MEMBER",
},
{
name: "곽빈",
email: "whathappen@naver.com",
profileImageUrl: null,
role: "MEMBER",
},
];

export default function MemberList() {
const [memberList, setMemberList] = useState<TWorkspaceMember[]>(mockMembers);

const handleRoleChange = (targetEmail: string, newRole: TMemberRole) => {
setMemberList((prev) =>
prev.map((member) =>
member.email === targetEmail ? { ...member, role: newRole } : member,
),
);
};
return (
<div className="bg-white border border-gray-100 rounded-component-lg p-8 shadow-Soft">
<header className="mb-7 flex justify-between">
<div>
<h2 className="font-heading4 text-text-main !font-semibold">
팀 구성원
</h2>
<p className="font-body2 text-text-sub mt-2">
현재 {mockMembers.length}명의 구성원이 활동 중입니다
</p>
</div>
<Button
type="button"
variant="primary"
size="small"
aria-label="팀원 초대 버튼"
onClick={() => alert("TODO:팀원초대모달버튼")}
// disabled={}
className="p-5 py-6 rounded-component-md"
>
<PlusIcon className="w-3 h-3 fill-white" />
팀원 초대
</Button>
</header>

<ul className="divide-y divide-gray-100">
{memberList.map((member) => (
<MemberItem
key={member.email}
member={member}
onRoleChange={(newRole) => handleRoleChange(member.email, newRole)}
/>
))}
</ul>
</div>
);
}
105 changes: 105 additions & 0 deletions src/components/workspace/MemberRoleSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { useEffect, useId, useMemo, useRef, useState } from "react";

import type { TMemberRole } from "@/types/workspace/workspace";

import ChevronIcon from "@/assets/icon/sidebar/chevron-left.svg?react";

type TProps = {
role: TMemberRole;
onChange: (newRole: TMemberRole) => void;
disabled?: boolean;
};

const roleLabelMap: Record<TMemberRole, string> = {
ADMIN: "관리자",
MEMBER: "멤버",
};

const roleOptions: TMemberRole[] = ["ADMIN", "MEMBER"];

const triggerStyleMap: Record<TMemberRole, string> = {
ADMIN: "bg-chart-1/18 text-chart-1",
MEMBER: "bg-gray-100 text-text-auth-sub",
};

export default function MemberRoleSelect({
role,
onChange,
disabled = false,
}: TProps) {
const [isOpen, setIsOpen] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
Comment thread
jjjsun marked this conversation as resolved.
Resolved
const menuId = useId();

useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (!wrapperRef.current) return;
if (!wrapperRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);

useEffect(() => {
if (disabled) {
setIsOpen(false);
}
}, [disabled]);

const restOptions = useMemo(
() => roleOptions.filter((option) => option !== role),
[role],
);

const handleSelect = (newRole: TMemberRole) => {
onChange(newRole);
setIsOpen(false);
};
return (
<div ref={wrapperRef} className="relative">
<div
className={`overflow-hidden rounded-[22px] ${disabled ? "opacity-50" : ""}`}
>
<button
type="button"
disabled={disabled}
aria-expanded={isOpen}
aria-haspopup="menu"
aria-controls={isOpen && !disabled ? menuId : undefined}
onClick={() => setIsOpen((prev) => !prev)}
className={`flex h-10 min-w-[98px] items-center justify-between gap-4 px-4 font-body2 transition-all ${
triggerStyleMap[role]
} ${disabled ? "cursor-not-allowed" : "cursor-pointer"}`}
>
<span>{roleLabelMap[role]}</span>
<ChevronIcon
className={`h-4 w-4 shrink-0 transition-transform ${isOpen ? "rotate-90" : "-rotate-90"}`}
/>
Comment thread
jjjsun marked this conversation as resolved.
Resolved
</button>
{isOpen && !disabled && (
<div
id={menuId}
role="menu"
className={`overflow-hidden bg-gray-100 transition-all duration-200 ease-out ${isOpen && !disabled ? "max-h-10 opacity-100" : "max-h-0 opacity-0"}`}
>
{restOptions.map((option) => (
<button
type="button"
key={option}
role="menuitem"
onClick={() => handleSelect(option)}
className={`flex h-10 w-full items-center justify-start px-7 font-body2 text-text-auth-sub transition-all hover:bg-gray-200`}
>
{roleLabelMap[option]}
</button>
))}
</div>
Comment thread
jjjsun marked this conversation as resolved.
Resolved
)}
</div>
</div>
);
}
Loading
Loading