Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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.
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