Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FE] feat: 사용자 프로필 정보 및 프로필 탭 구현 #1035

Open
wants to merge 9 commits into
base: develop
Choose a base branch
from
3 changes: 3 additions & 0 deletions frontend/src/assets/github.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 frontend/src/assets/logout.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 frontend/src/assets/menu.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 frontend/src/assets/openedBook.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 frontend/src/assets/user.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
37 changes: 37 additions & 0 deletions frontend/src/components/profile/ProfileInfo/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import DownArrowIcon from '@/assets/downArrow.svg';
import UndraggableWrapper from '@/components/common/UndraggableWrapper';
import { SocialType } from '@/types/profile';

import ProfileTab from '../ProfileTab';
import useProfile from '../ProfileTab/hooks/useProfile';
import useProfileTabElements from '../ProfileTab/hooks/useProfileTabElements';

import * as S from './styles';

interface ProfileInfoProps {
profileImageSrc?: string;
profileId: string;
socialType: SocialType;
}

const ProfileInfo = ({ profileImageSrc, profileId, socialType }: ProfileInfoProps) => {
const { isOpened, containerRef, handleContainerClick } = useProfile();
const { profileTabElements } = useProfileTabElements({ profileId, socialType });

return (
<S.ProfileSection ref={containerRef}>
<UndraggableWrapper>
<S.ProfileContainer onClick={handleContainerClick}>
<S.ProfileImageWrapper>
{profileImageSrc && <img src={profileImageSrc} alt="프로필 사진" />}
</S.ProfileImageWrapper>
<S.ProfileId>{profileId}</S.ProfileId>
<S.ArrowIcon src={DownArrowIcon} $isOpened={isOpened} alt="" />
</S.ProfileContainer>
</UndraggableWrapper>
{isOpened && <ProfileTab items={profileTabElements} />}
</S.ProfileSection>
);
};

export default ProfileInfo;
52 changes: 52 additions & 0 deletions frontend/src/components/profile/ProfileInfo/styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import styled from '@emotion/styled';

import media from '@/utils/media';

interface DropdownStyleProps {
$isOpened: boolean;
}

export const ProfileSection = styled.section`
cursor: pointer;
position: relative;
width: fit-content;
`;

export const ProfileContainer = styled.div`
display: flex;
gap: 1rem;
align-items: center;
padding: 0 1rem;
`;

export const ProfileImageWrapper = styled.div`
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;

width: 4rem;
height: 4rem;

background-color: ${({ theme }) => theme.colors.gray};
border-radius: 2rem;
`;

export const ProfileId = styled.p`
font-weight: ${({ theme }) => theme.fontWeight.semibold};

${media.small} {
display: none;
}
`;

export const ArrowIcon = styled.img<DropdownStyleProps>`
transform: ${({ $isOpened }) => ($isOpened ? 'rotate(180deg)' : 'rotate(0deg)')};
width: 2rem;
height: 2rem;
transition: transform 0.3s ease-in-out;

${media.small} {
display: none;
}
`;
28 changes: 28 additions & 0 deletions frontend/src/components/profile/ProfileTab/hooks/useProfile.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { useEffect, useRef, useState } from 'react';

const useProfile = () => {
const [isOpened, setIsOpened] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);

const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpened(false);
}
};

useEffect(() => {
document.addEventListener('mousedown', handleClickOutside);

return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [containerRef]);

const handleContainerClick = () => {
setIsOpened((prev) => !prev);
};

return { isOpened, containerRef, handleContainerClick };
};

export default useProfile;
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { useNavigate } from 'react-router';

import GitHubIcon from '@/assets/github.svg';
import LogoutIcon from '@/assets/logout.svg';
import MenuIcon from '@/assets/menu.svg';
import OpenedBookIcon from '@/assets/openedBook.svg';
import UserIcon from '@/assets/user.svg';
import { ProfileTabElement, SocialType } from '@/types/profile';

interface UseProfileTabElementsProps {
profileId: string;
socialType: SocialType;
}

const useProfileTabElements = ({ profileId, socialType }: UseProfileTabElementsProps) => {
const navigate = useNavigate();

const handleReviewLinkControl = () => {
// 리뷰 링크 관리 페이지로 이동
console.log('리뷰 링크 관리 클릭');
};

const handleCheckWrittenReviews = () => {
// 작성한 리뷰 확인 페이지로 이동
console.log('작성한 리뷰 확인 클릭');
};

const handleLogout = () => {
// 로그아웃 로직
console.log('로그아웃 클릭');
};

const profileTabElements: ProfileTabElement[] = [
{
elementType: 'readonly',
isDisplayedOnlyMobile: false,
content: socialType === 'github' && (
<div style={{ display: 'flex', gap: '1rem' }}>
<img src={GitHubIcon} alt="소셜 아이콘" />
<span>GitHub 계정</span>
</div>
),
},
{
elementType: 'readonly',
isDisplayedOnlyMobile: true,
content: (
<div style={{ display: 'flex', gap: '1rem' }}>
<img src={UserIcon} alt="사람 아이콘" />
<span>{profileId}</span>
</div>
),
},
{
elementType: 'action',
isDisplayedOnlyMobile: false,
content: (
<div style={{ display: 'flex', gap: '1rem' }}>
<img src={MenuIcon} alt="메뉴 아이콘" />
<span>리뷰 링크 관리</span>
</div>
),
handleClick: handleReviewLinkControl,
},
{
elementType: 'action',
isDisplayedOnlyMobile: false,
content: (
<div style={{ display: 'flex', gap: '1rem' }}>
<img src={OpenedBookIcon} alt="펼쳐진 책 아이콘" />
<span>작성한 리뷰 확인</span>
</div>
),
handleClick: handleCheckWrittenReviews,
},
{
elementType: 'divider',
isDisplayedOnlyMobile: false,
},
{
elementType: 'action',
isDisplayedOnlyMobile: false,
content: (
<div style={{ display: 'flex', gap: '1rem' }}>
<img src={LogoutIcon} alt="펼쳐진 책 아이콘" />
<span>로그아웃</span>
</div>
),
handleClick: handleLogout,
},
];

return { profileTabElements };
};

export default useProfileTabElements;
46 changes: 46 additions & 0 deletions frontend/src/components/profile/ProfileTab/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import UndraggableWrapper from '@/components/common/UndraggableWrapper';
import { ProfileTabElement } from '@/types/profile';

import * as S from './styles';

interface ProfileTabProps {
items: ProfileTabElement[];
}

const ProfileTab = ({ items }: ProfileTabProps) => {
return (
<S.ProfileTabContainer>
<UndraggableWrapper>
{items.map((item, index) => {
switch (item.elementType) {
case 'readonly':
return (
<S.ReadonlyItemWrapper
key={`${item.elementType}_${index}`}
$isDisplayedOnlyMobile={item.isDisplayedOnlyMobile}
>
{item.content}
</S.ReadonlyItemWrapper>
);
case 'action':
return (
<S.ActionItemWrapper
key={`${item.elementType}_${index}`}
onClick={item.handleClick}
$isDisplayedOnlyMobile={item.isDisplayedOnlyMobile}
>
{item.content}
</S.ActionItemWrapper>
);
case 'divider':
return (
<S.Divider key={`${item.elementType}_${index}`} $isDisplayedOnlyMobile={item.isDisplayedOnlyMobile} />
);
}
})}
</UndraggableWrapper>
</S.ProfileTabContainer>
);
};

export default ProfileTab;
78 changes: 78 additions & 0 deletions frontend/src/components/profile/ProfileTab/styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import styled from '@emotion/styled';

import media from '@/utils/media';

interface ProfileTabStyleProps {
$isDisplayedOnlyMobile: boolean;
}

export const ProfileTabContainer = styled.div`
position: absolute;
z-index: ${({ theme }) => theme.zIndex.profileTab};
top: 5rem;
right: 0;

display: flex;
flex-direction: column;

width: max-content;
min-width: 100%;
height: fit-content;
padding: 1rem;

background-color: ${({ theme }) => theme.colors.white};
border-radius: 0.8rem;
box-shadow:
0 0.5rem 0.5rem -0.3rem rgba(0, 0, 0, 0.2),
0 0.8rem 1rem 0.1rem rgba(0, 0, 0, 0.14),
0 0.3rem 1.4rem 0.2rem rgba(0, 0, 0, 0.12);
`;

export const ReadonlyItemWrapper = styled.div<ProfileTabStyleProps>`
cursor: default;

display: ${({ $isDisplayedOnlyMobile }) => ($isDisplayedOnlyMobile ? 'none' : 'flex')};
align-items: center;

height: 3rem;
padding: 1rem;

${media.small} {
display: ${({ $isDisplayedOnlyMobile }) => $isDisplayedOnlyMobile && 'flex'};
}
`;

export const ActionItemWrapper = styled.div<ProfileTabStyleProps>`
cursor: pointer;

display: ${({ $isDisplayedOnlyMobile }) => ($isDisplayedOnlyMobile ? 'none' : 'flex')};
align-items: center;

height: 3rem;
padding: 1rem;

border-radius: 0.8rem;

:hover {
background-color: ${({ theme }) => theme.colors.lightGray};
}

${media.small} {
display: ${({ $isDisplayedOnlyMobile }) => $isDisplayedOnlyMobile && 'flex'};
}
`;

export const Divider = styled.hr<ProfileTabStyleProps>`
display: ${({ $isDisplayedOnlyMobile }) => ($isDisplayedOnlyMobile ? 'none' : 'block')};

width: 100%;
height: 0;
margin: 0.5rem 0;
padding: 0;

border: 0.1rem solid ${({ theme }) => theme.colors.placeholder};

${media.small} {
display: ${({ $isDisplayedOnlyMobile }) => $isDisplayedOnlyMobile && 'block'};
}
`;
3 changes: 2 additions & 1 deletion frontend/src/styles/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ export const colors: ThemeProperty<CSSProperties['color']> = {

export const zIndex: ThemeProperty<CSSProperties['zIndex']> = {
main: 1,
dropdown: 998,
dropdown: 997,
profileTab: 998,
Comment on lines +80 to +81
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

프로필 탭은 모달 이외의 다른 모든 요소보다는 위에 떠야 하기 때문에 z-index 값을 조정했습니다.

modal: 999,
};

Expand Down
10 changes: 10 additions & 0 deletions frontend/src/types/profile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export type SocialType = 'github';

export type ProfileTabElementType = 'readonly' | 'action' | 'divider';

export interface ProfileTabElement {
elementType: ProfileTabElementType;
isDisplayedOnlyMobile: boolean; // true: 모바일만, false: 전체
content?: React.ReactNode; // divider 제외 지정
handleClick?: () => void; // action일 때 지정
}
Loading