diff --git a/src/apis/members/quries.ts b/src/apis/members/queries.ts similarity index 76% rename from src/apis/members/quries.ts rename to src/apis/members/queries.ts index b02a725..314078d 100644 --- a/src/apis/members/quries.ts +++ b/src/apis/members/queries.ts @@ -4,7 +4,7 @@ import { DeleteMemberRequest, GetMembersRequest } from './types'; export const useMembersQuery = (params: GetMembersRequest) => { return useQuery({ - queryKey: ['members', params.dashboardId], + queryKey: ['members', params], queryFn: () => getMembers(params), }); }; @@ -16,8 +16,8 @@ export const useRemoveMember = () => { mutationFn: (params: DeleteMemberRequest) => { return deleteMember(params); }, - onSuccess: (_, { dashboardId }) => { - queryClient.invalidateQueries({ queryKey: ['members', dashboardId] }); + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['members'] }); }, }); }; diff --git a/src/apis/users/queries.ts b/src/apis/users/queries.ts new file mode 100644 index 0000000..1a17f90 --- /dev/null +++ b/src/apis/users/queries.ts @@ -0,0 +1,9 @@ +import { useQuery } from '@tanstack/react-query'; +import { getUser } from '.'; + +export const useGetUser = () => { + return useQuery({ + queryKey: ['user'], + queryFn: () => getUser(), + }); +}; diff --git a/src/app/(after-login)/layout.tsx b/src/app/(after-login)/layout.tsx index b18c0b0..722fbab 100644 --- a/src/app/(after-login)/layout.tsx +++ b/src/app/(after-login)/layout.tsx @@ -14,7 +14,7 @@ export default function layout({ children }: PropsWithChildren) {
{/* header */}
-
+
{/* content page */} diff --git a/src/components/dashboard-header/DashboardInfo.tsx b/src/components/dashboard-header/DashboardInfo.tsx new file mode 100644 index 0000000..160d606 --- /dev/null +++ b/src/components/dashboard-header/DashboardInfo.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { useRef } from 'react'; +import { useParams } from 'next/navigation'; +import Image from 'next/image'; +import useMediaQuery from '@/hooks/useMediaQuery'; +import { useDashboardQuery } from '@/apis/dashboards/queries'; +import { useMembersQuery } from '@/apis/members/queries'; +import StackAvatars from '@/components/ui/Avatar/StackAvatars'; +import { ModalHandle } from '@/components/ui/Modal/Modal'; +import InviteDashboard from '@/components/dashboard/InviteDashboard'; +import HeaderButton from '@/components/dashboard-header/HeaderButton'; +import Setting from '@/assets/icons/setting.svg'; +import AddBox from '@/assets/icons/add_box.svg'; +import crown from '@/assets/icons/crown.svg'; + +export default function DashboardInfo() { + const { id } = useParams(); + const { data: dashboardDetail } = useDashboardQuery(Number(id)); + const { data: membersData } = useMembersQuery({ dashboardId: Number(id), page: 1, size: 4 }); + const inviteModalRef = useRef(null); + + const isDesktop = useMediaQuery('(min-width: 1024px)'); + const isTablet = useMediaQuery('(min-width: 768px)'); + const visibleCount = isDesktop ? 4 : isTablet ? 3 : 2; + + return ( +
+ {/* 대시보드 방 제목 */} +

+ {dashboardDetail?.title} + {dashboardDetail?.createdByMe && crown} +

+ +
+ {/* 대시보드 관리자만 보임 : 대시보드 관리 버튼) */} + {dashboardDetail?.createdByMe && ( +
+ + 관리 + 관리 + + inviteModalRef.current?.open()}> + 초대하기 + 초대하기 + + + {/* 초대모달 */} + +
+ )} + + {/* 모든 맴버가 보임 : 대시보드 맴버목록 */} + +
+
+ ); +} diff --git a/src/components/dashboard-header/Header.tsx b/src/components/dashboard-header/Header.tsx index 38c0d3a..ea55e92 100644 --- a/src/components/dashboard-header/Header.tsx +++ b/src/components/dashboard-header/Header.tsx @@ -1,71 +1,25 @@ 'use client'; -import React from 'react'; -import { usePathname } from 'next/navigation'; -import Image from 'next/image'; -import Crown from '@/assets/icons/crown.svg'; -import Title from './Title'; -import SettingBtn from './SettingBtn'; -import Members from './Members'; -import Profile from './Profile'; +import { useParams, useSelectedLayoutSegment } from 'next/navigation'; +import Profile from '@/components/dashboard-header/Profile'; +import DashboardInfo from '@/components/dashboard-header/DashboardInfo'; +import { PAGE_TITLE } from '@/constants/dashboard'; -interface HeaderProps { - title?: string; - showCrown?: boolean; - showSetting?: boolean; - showMembers?: boolean; - showProfile?: boolean; -} - -const titleMap: { [key: string]: string } = { - '/mydashboard': '내 대시보드', - '/mypage': '계정관리', -}; - -// TODO : API 연동 이후 추가 작업 예정, 우선 mock 데이터로 집어넣어놨습니다. -interface UserType { - nickname: string; - profileImageUrl: string; -} - -const mockUsers: UserType[] = [ - { nickname: '배유철', profileImageUrl: 'B' }, - { nickname: '김철수', profileImageUrl: 'K' }, - { nickname: '이영희', profileImageUrl: 'L' }, -]; - -export default function Header({ showCrown = false, showSetting = false, showMembers = false, showProfile = false }: HeaderProps) { - const pathname = usePathname(); - const title = titleMap[pathname] || '내 대시보드'; - const handleSettingClick = () => { - console.log('관리 버튼 클릭'); - // TODO : 디버깅 용도 console.log API 기능 구현 후 수정 예정 - }; - - const handleInviteClick = () => { - console.log('초대 버튼 클릭'); - // TODO : 디버깅 용도 console.log API 기능 구현 후 수정 예정 - }; +export default function Header() { + const { id } = useParams(); + const isDashboardPage = !!id; - const handleProfileClick = () => { - console.log('프로필 버튼 클릭'); - // TODO : 디버깅 용도 console.log API 기능 구현 후 수정 예정 - }; + const segment = useSelectedLayoutSegment(); + const currentPageTitle = PAGE_TITLE[segment as keyof typeof PAGE_TITLE] || ''; return ( -
- {/* 왼쪽 제목 그룹 */} -
- - {showCrown && <Image src={Crown} alt='방장 표시' width={20} height={16} className='hidden lg:block' />} + <header className='flex h-[60px] w-full items-center justify-between border-b-2 border-gray-20 px-3 py-[15px] sm:h-[70px] md:px-8 lg:px-10'> + <div className='flex-1'> + {/* 페이지 제목 or 대시보드 정보 */} + {!isDashboardPage ? <h2 className='text-xl font-bold'>{currentPageTitle}</h2> : <DashboardInfo />} </div> - {/* 오른쪽 버튼 그룹 */} - <div className='flex items-center'> - <div className='flex items-center gap-[16px] border-r border-gray-30 pr-[12px] sm:gap-[32px] sm:pr-[24px] md:gap-[40px] md:pr-[32px]'> - {showSetting && <SettingBtn onSettingClick={handleSettingClick} onInviteClick={handleInviteClick} />} - {showMembers && <Members users={mockUsers} />} - </div> - {showProfile && <Profile nickname={mockUsers[0].nickname} profileImageUrl={mockUsers[0].profileImageUrl} onClick={handleProfileClick} />} + <div className='ml-auto'> + <Profile /> </div> </header> ); diff --git a/src/components/dashboard-header/HeaderButton.tsx b/src/components/dashboard-header/HeaderButton.tsx new file mode 100644 index 0000000..6a10e6f --- /dev/null +++ b/src/components/dashboard-header/HeaderButton.tsx @@ -0,0 +1,32 @@ +import { ButtonHTMLAttributes, PropsWithChildren } from 'react'; +import Link, { LinkProps } from 'next/link'; +import { cn } from '@/utils/helper'; + +interface HeaderButtonProps extends PropsWithChildren<ButtonHTMLAttributes<HTMLButtonElement>> { + href?: string; + linkProps?: Omit<LinkProps, 'href'>; // href를 제외한 Link의 props 추가로 전달 +} + +export default function HeaderButton({ children, className, href, linkProps, ...props }: HeaderButtonProps) { + const button = ( + <button + {...props} + className={cn( + 'flex h-10 items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-gray-30 bg-white px-4 py-[6px] font-medium leading-none text-gray-50 transition-all hover:text-gray-70 hover:shadow', + className, + )} + > + {children} + </button> + ); + + if (href) { + return ( + <Link href={href} {...linkProps}> + {button} + </Link> + ); + } + + return button; +} diff --git a/src/components/dashboard-header/Members.tsx b/src/components/dashboard-header/Members.tsx deleted file mode 100644 index 568c7d5..0000000 --- a/src/components/dashboard-header/Members.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; - -interface MembersProps { - users: { profileImageUrl: string }[]; -} - -export default function Members({ users }: MembersProps) { - return ( - <div className='flex'> - {users.map((user, index) => ( - <span key={index} className='-ml-[8px] flex h-[34px] w-[34px] items-center justify-center rounded-full border-2 border-white bg-green-30 text-white sm:h-[38px] sm:w-[38px]'> - {user.profileImageUrl} - </span> - ))} - </div> - ); -} diff --git a/src/components/dashboard-header/Profile.tsx b/src/components/dashboard-header/Profile.tsx index 327c094..9bea245 100644 --- a/src/components/dashboard-header/Profile.tsx +++ b/src/components/dashboard-header/Profile.tsx @@ -1,16 +1,83 @@ -import React from 'react'; +'use client'; -interface ProfileProps { - nickname: string; - profileImageUrl: string; - onClick: () => void; +import { useEffect, useRef, useState } from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useQueryClient } from '@tanstack/react-query'; +import { motion } from 'motion/react'; +import { useGetUser } from '@/apis/users/queries'; +import Avatar from '@/components/ui/Avatar/Avatar'; +import axiosClientHelper from '@/utils/network/axiosClientHelper'; +import useAlert from '@/hooks/useAlert'; + +export default function Profile() { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const { data, isFetching } = useGetUser(); + const alert = useAlert(); + const router = useRouter(); + const menuRef = useRef<HTMLDivElement | null>(null); + const queryClient = useQueryClient(); + + const handleLogout = async () => { + await axiosClientHelper.post('/auth/logout'); + await alert('로그아웃 했습니다.'); + queryClient.invalidateQueries(); + router.replace('/'); + }; + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setIsMenuOpen(false); + } + } + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + return ( + <> + {isFetching ? ( + <Skeleton /> + ) : ( + data && ( + <div ref={menuRef} className='group relative flex cursor-pointer items-center gap-3 leading-none' onClick={() => setIsMenuOpen((prev) => !prev)}> + <Avatar email={data.email} className='transition-shadow group-hover:shadow-sm group-hover:shadow-slate-400' /> + <span className='hidden font-medium md:block'>{data.nickname}</span> + + {isMenuOpen && ( + <motion.ul // + initial={{ scale: 0.8, y: -20 }} + animate={{ scale: 1, y: 0 }} + className='absolute right-0 top-[calc(100%+6px)] w-28 rounded-md border border-gray-30 bg-white p-[4px]' + > + <li> + <Link href='/mypage' className='flex h-10 w-full items-center justify-center rounded-md text-md hover:bg-violet-10 hover:font-medium hover:text-violet-20'> + 내정보 + </Link> + </li> + <li> + <button className='flex h-10 w-full items-center justify-center text-md hover:bg-violet-10 hover:font-medium hover:text-violet-20' onClick={handleLogout}> + 로그아웃 + </button> + </li> + </motion.ul> + )} + </div> + ) + )} + </> + ); } -export default function Profile({ nickname, profileImageUrl, onClick }: ProfileProps) { +function Skeleton() { return ( - <button onClick={onClick} className='mr-[40px] flex items-center justify-center gap-[12px] pl-[12px] sm:pl-[24px] md:pl-[32px]'> - <span className='flex h-[34px] w-[34px] items-center justify-center rounded-full border-2 border-white bg-green-30 text-white sm:h-[38px] sm:w-[38px]'>{profileImageUrl}</span> - <span className='hidden text-[16px] md:block'>{nickname}</span> - </button> + <div className='flex items-center gap-3 leading-none'> + <div className='flex aspect-square w-9 animate-pulse rounded-full bg-gray-20' /> + <span className='block h-4 w-16 animate-pulse rounded-md bg-gray-20' /> + </div> ); } diff --git a/src/components/dashboard-header/SettingBtn.tsx b/src/components/dashboard-header/SettingBtn.tsx deleted file mode 100644 index 679aca8..0000000 --- a/src/components/dashboard-header/SettingBtn.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from 'react'; -import Image from 'next/image'; -import Setting from '@/assets/icons/setting.svg'; -import AddBox from '@/assets/icons/add_box.svg'; - -interface SettingBtnProps { - onSettingClick: () => void; - onInviteClick: () => void; -} - -export default function SettingBtn({ onSettingClick, onInviteClick }: SettingBtnProps) { - return ( - <div className='flex items-center gap-[6px] sm:gap-[12px] md:gap-[16px]'> - <button - className='flex h-[30px] w-[49px] items-center justify-center gap-[8px] rounded-[8px] border border-gray-30 text-[14px] text-gray-50 transition-transform hover:scale-105 hover:border-violet-20 hover:bg-violet-20 hover:text-white sm:h-[40px] sm:w-[88px] md:text-[16px]' - onClick={onSettingClick} - > - <Image src={Setting} alt='관리 버튼' width={15} height={15} className='hidden sm:block' /> - 관리 - </button> - <button - className='flex h-[30px] w-[73px] items-center justify-center gap-[8px] rounded-[8px] border border-gray-30 text-[14px] text-gray-50 transition-transform hover:scale-105 hover:border-violet-20 hover:bg-violet-20 hover:text-white sm:h-[40px] sm:w-[116px] md:text-[14px]' - onClick={onInviteClick} - > - <Image src={AddBox} alt='초대 버튼' width={15} height={15} className='hidden sm:block' /> - 초대하기 - </button> - </div> - ); -} diff --git a/src/components/dashboard-header/Title.tsx b/src/components/dashboard-header/Title.tsx deleted file mode 100644 index c124769..0000000 --- a/src/components/dashboard-header/Title.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; - -interface TitleProps { - title?: string; -} - -export default function Title({ title }: TitleProps) { - return ( - <div className='flex items-center'> - <span className='hidden text-[20px] font-bold lg:block'>{title}</span> - </div> - ); -} diff --git a/src/components/dashboard/DetailMembers.tsx b/src/components/dashboard/DetailMembers.tsx index aa8c56b..a38519c 100644 --- a/src/components/dashboard/DetailMembers.tsx +++ b/src/components/dashboard/DetailMembers.tsx @@ -4,7 +4,7 @@ import { isAxiosError } from 'axios'; import { useState } from 'react'; import { useParams } from 'next/navigation'; import useAlert from '@/hooks/useAlert'; -import { useMembersQuery, useRemoveMember } from '@/apis/members/quries'; +import { useMembersQuery, useRemoveMember } from '@/apis/members/queries'; import { getErrorMessage } from '@/utils/errorMessage'; import PaginationWithCounter from '@/components/pagination/PaginationWithCounter'; import { Table, TableBody, TableCell, TableCol, TableColGroup, TableHead, TableHeadCell, TableRow } from '@/components/ui/Table/Table'; diff --git a/src/components/ui/Avatar/Avatar.tsx b/src/components/ui/Avatar/Avatar.tsx index b7eea99..a3bcd9f 100644 --- a/src/components/ui/Avatar/Avatar.tsx +++ b/src/components/ui/Avatar/Avatar.tsx @@ -28,7 +28,7 @@ import { HTMLAttributes, useState } from 'react'; const avatarVariants = cva( //prettier-ignore - 'relative inline-flex items-center justify-center aspect-square rounded-full overflow-hidden border-2 border-white leading-none', + 'relative inline-flex bg-white items-center justify-center aspect-square rounded-full overflow-hidden border-2 border-white leading-none', { variants: { size: { @@ -46,7 +46,7 @@ const avatarVariants = cva( interface AvatarProps extends HTMLAttributes<HTMLDivElement>, VariantProps<typeof avatarVariants> { email: string; - profileImageUrl?: string; + profileImageUrl?: string | null; } export default function Avatar({ email, profileImageUrl, size, className, ...props }: AvatarProps) { @@ -59,7 +59,7 @@ export default function Avatar({ email, profileImageUrl, size, className, ...pro return ( <figure className={cn(avatarVariants({ size, className }))} {...props}> {!isFallback ? ( - <Image src={profileImageUrl} alt={email} fill onError={() => setImgError(true)} /> + <Image src={profileImageUrl} alt={email} fill onError={() => setImgError(true)} className='object-cover' /> ) : ( <span className='flex aspect-square w-full items-center justify-center font-semibold uppercase text-white' style={{ backgroundColor: colorCode }}> {firstChar} diff --git a/src/components/ui/Avatar/StackAvatars.tsx b/src/components/ui/Avatar/StackAvatars.tsx index df31494..8696038 100644 --- a/src/components/ui/Avatar/StackAvatars.tsx +++ b/src/components/ui/Avatar/StackAvatars.tsx @@ -1,26 +1,30 @@ +import { Member } from '@/apis/members/types'; import Avatar from './Avatar'; interface StackAvatarsProps { - members: { email: string; profileImageUrl?: string }[]; + members: Pick<Member, 'email' | 'profileImageUrl'>[]; visibleCount: number; + totalCount: number; } -export default function StackAvatars({ members, visibleCount = 3 }: StackAvatarsProps) { +export default function StackAvatars({ members, visibleCount = 3, totalCount }: StackAvatarsProps) { const chunkedMembers = members.slice(0, visibleCount); - const restMembersCount = members.length - visibleCount; + const restMembersCount = totalCount - visibleCount; return ( - <ul className='flex pl-2 leading-none'> - {chunkedMembers.map((member) => ( - <li key={member.email} className='-ml-2'> - <Avatar email={member.email} profileImageUrl={member.profileImageUrl} /> - </li> - ))} - {restMembersCount > 0 && ( - <li className='-ml-2'> - <span className='relative flex aspect-square h-full items-center justify-center rounded-full border-2 border-white bg-[#F4D7DA] font-medium text-[#D25B68]'>+{restMembersCount}</span> - </li> - )} - </ul> + <div className='pl-2'> + <ul className='flex leading-none'> + {chunkedMembers.map((member) => ( + <li key={member.email} className='-ml-2 w-10'> + <Avatar email={member.email} profileImageUrl={member.profileImageUrl} /> + </li> + ))} + {restMembersCount > 0 && ( + <li className='-ml-2 w-10'> + <span className='relative flex aspect-square h-full items-center justify-center rounded-full border-2 border-white bg-[#F4D7DA] font-medium text-[#D25B68]'>+{restMembersCount}</span> + </li> + )} + </ul> + </div> ); } diff --git a/src/constants/dashboard.ts b/src/constants/dashboard.ts index e304b01..e8dbac5 100644 --- a/src/constants/dashboard.ts +++ b/src/constants/dashboard.ts @@ -14,3 +14,8 @@ export const DASHBOARD_FORM_ERROR_MESSAGE = { INVALID: '이메일 형식으로 작성해 주세요', }, } as const; + +export const PAGE_TITLE = { + mydashboard: '내 대시보드', + mypage: '계정관리', +} as const; diff --git a/src/hooks/useMediaQuery.ts b/src/hooks/useMediaQuery.ts new file mode 100644 index 0000000..961fddb --- /dev/null +++ b/src/hooks/useMediaQuery.ts @@ -0,0 +1,18 @@ +import { useEffect, useState } from 'react'; + +export default function useMediaQuery(query: string) { + const [isMatch, setIsMatch] = useState(false); + + useEffect(() => { + const mediaQuery = window.matchMedia(query); + setIsMatch(mediaQuery.matches); + + const handleChange = (e: MediaQueryListEvent) => setIsMatch(e.matches); + + mediaQuery.addEventListener('change', handleChange); + + return () => mediaQuery.removeEventListener('change', handleChange); + }, [query]); + + return isMatch; +}