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

프로필 바텀 시트 UI 구현 #744

Merged
merged 10 commits into from
Oct 24, 2024
3 changes: 3 additions & 0 deletions frontend/src/apis/endPoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ const API_URL = {
},
kakaoOAuth: addBaseUrl('/auth/kakao/oauth', false),
myInfo: addBaseUrl('/member/mine', true),

profile: (darakbangMemberId: number) =>
addBaseUrl(`/members/${darakbangMemberId}/profile`, true),
};

const ENDPOINTS = {
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/apis/gets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
GetChatRoomDetail,
GetChattingPreview,
GetDarakbangInviteCode,
GetDarakbangMemberProfile,
GetDarakbangMembers,
GetDarakbangMine,
GetDarakbangNameByCode,
Expand Down Expand Up @@ -237,3 +238,12 @@ export const getBetResult = async (betId: number) => {
const json: GetBetDetail = await response.json();
return json.data.nickname;
};

export const getDarakbangMemberProfile = async (darakbangMemberId: number) => {
const response = await ApiClient.getWithLastDarakbangId(
`/members/${darakbangMemberId}/profile`,
);

const json: GetDarakbangMemberProfile = await response.json();
return json.data;
};
12 changes: 11 additions & 1 deletion frontend/src/apis/responseTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export interface GetMyRoleInDarakbang {
export interface GetDarakbangMembers {
data: {
responses: {
memberId: number;
darakbangMemberId: number;
nickname: string;
profile: string;
}[];
Expand Down Expand Up @@ -136,3 +136,13 @@ export interface PostBet {
betId: number;
};
}

export interface GetDarakbangMemberProfile {
data: {
darakbangMemberId: number;
name: string;
nickname: string;
url: string;
description: string;
};
}
30 changes: 30 additions & 0 deletions frontend/src/components/BottomSheet/BottomSheet.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { Meta, StoryObj } from '@storybook/react';
import BottomSheet from './BottomSheet';
import Button from '@_components/Button/Button';

const meta = {
component: BottomSheet,
title: 'Components/BottomSheet',
} satisfies Meta<typeof BottomSheet>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {
args: {
isOpen: true,
onDimmerClick: () => {},
header: <BottomSheet.Header>Header</BottomSheet.Header>,
cta: (
<BottomSheet.CTA>
<Button shape="bar">CTA</Button>
</BottomSheet.CTA>
),
},
render: (args) => (
<BottomSheet {...args}>
<BottomSheet.Main>Content</BottomSheet.Main>
</BottomSheet>
),
};
136 changes: 136 additions & 0 deletions frontend/src/components/BottomSheet/BottomSheet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import Dimmer from '@_components/Dimmer/Dimmer';
import { PropsWithChildren, useEffect, useState } from 'react';
import BottomSheetContainer from './BottomSheetContainer/BottomSheetContainer';
import BottomSheetBody from './BottomSheetBody/BottomSheetBody';
import BottomSheetHandle from './BottomSheetHandle/BottomSheetHandle';

interface BottomSheetProps {
isOpen: boolean;
onDimmerClick: () => void;

header?: React.ReactNode;
cta?: React.ReactNode;

size?: 'small' | 'medium' | 'large' | 'full';
}

export default function BottomSheet(
props: PropsWithChildren<BottomSheetProps>,
) {
const { isOpen, onDimmerClick, header, cta, children, size } = props;

const [startY, setStartY] = useState(0); // 터치 시작 Y좌표
const [currentY, setCurrentY] = useState(window.innerHeight); // 초기에는 화면 아래에 위치
const [isDragging, setIsDragging] = useState(false); // 드래그 중인지 여부
const [isClosing, setIsClosing] = useState(false); // 닫히는 애니메이션 여부

// Bottom Sheet가 열릴 때 애니메이션으로 위로 올라옴
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden'; // 스크롤 비활성화

// 열릴 때 100px 아래에서 0으로 부드럽게 올라오도록 애니메이션 시작
setTimeout(() => {
setCurrentY(0); // Y값을 0으로 변경 -> 밑에서 위로 올라오는 애니메이션
}, 10); // 딜레이를 줘야 애니메이션이 자연스럽게 동작
} else {
setCurrentY(window.innerHeight); // 닫힐 때 다시 화면 아래로
document.body.style.overflow = 'auto'; // 스크롤 활성화
}

return () => {
document.body.style.overflow = 'auto';
};
}, [isOpen]);

useEffect(() => {
if (!isOpen) {
setCurrentY(window.innerHeight); // 닫힐 때 높이를 초기화
setIsClosing(false); // 닫힘 애니메이션 초기화
}
}, [isOpen]);

// Dimmer 클릭 시 애니메이션으로 밑으로 내려간 후 닫힘
const handleDimmerClick = () => {
setIsClosing(true);
setCurrentY(window.innerHeight); // 화면 아래로 내려가는 애니메이션
setTimeout(() => {
onDimmerClick(); // 300ms 후 실제로 닫기
}, 300); // 애니메이션 시간이 0.3초이므로 300ms 후에 닫음
};

// 드래그 시작
const handleTouchStart = (event: React.TouchEvent) => {
setStartY(event.touches[0].clientY);
setIsDragging(true);
};

// 드래그 중
const handleTouchMove = (event: React.TouchEvent) => {
if (!isDragging) return;

const currentTouchY = event.touches[0].clientY;
const deltaY = currentTouchY - startY;

// Y축으로만 움직임을 감지
if (deltaY > 0) {
setCurrentY(deltaY); // 드래그된 만큼 값을 저장
}
};

// 드래그 종료
const handleTouchEnd = () => {
if (!isDragging) return;
setIsDragging(false);

// 드래그가 일정 값 이상이면 Bottom Sheet를 닫음
if (currentY > 100) {
// 애니메이션으로 Bottom Sheet를 아래로 내리고 닫기
setIsClosing(true);
setCurrentY(window.innerHeight); // 화면 하단으로 내리는 애니메이션
setTimeout(() => {
onDimmerClick(); // 애니메이션 후 실제로 닫기
}, 300); // 애니메이션 시간이 0.3초이므로 300ms 후에 닫음
} else {
setCurrentY(0); // 원래 위치로 되돌림
}
};

if (!isOpen && !isClosing) {
return null;
}

return (
<BottomSheetContainer>
<Dimmer onClick={handleDimmerClick} />
<BottomSheetBody currentY={currentY} size={size} isDragging={isDragging}>
<BottomSheetHandle
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
/>
{header}
{children}
{cta}
</BottomSheetBody>
</BottomSheetContainer>
);
}

BottomSheet.Header = function BottomSheetHeader(props: PropsWithChildren) {
const { children } = props;

return <div css={{ padding: '0 24px' }}>{children}</div>;
};

BottomSheet.Main = function BottomSheetMain(props: PropsWithChildren) {
const { children } = props;

return <div css={{ padding: '0px 24px' }}>{children}</div>;
};

BottomSheet.CTA = function BottomSheetCTA(props: PropsWithChildren) {
const { children } = props;

return <div css={{ padding: '0px 24px' }}>{children}</div>;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { css, Theme } from '@emotion/react';

export const body = ({
theme,
currentY,
size,
isDragging,
}: {
theme: Theme;
currentY: number;
size?: 'small' | 'medium' | 'large' | 'full';
isDragging: boolean;
}) => css`
z-index: 2;

/* 터치 드래그에 따른 Y축 이동 */
transform: translateY(${currentY}px);

display: flex;
flex-direction: column;
gap: 24px;

width: 100%;
max-width: 600px;
height: ${size === 'medium'
? '50vh'
: size === 'large'
? '80vh'
: size === 'full'
? '100vh'
: 'auto'};
padding-bottom: 32px;

background-color: ${theme.colorPalette.white[100]};
border-radius: 28px 28px 0 0;

/* 터치 드래그에 따른 Y축 이동 */
transition: ${isDragging ? 'none' : 'transform 0.3s ease'};
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useTheme } from '@emotion/react';
import { PropsWithChildren } from 'react';
import * as S from './BottomSheetBody.style';

interface BottomSheetBodyProps {
currentY: number;
size?: 'small' | 'medium' | 'large' | 'full';
isDragging: boolean;
}

export default function BottomSheetBody(
props: PropsWithChildren<BottomSheetBodyProps>,
) {
const { currentY, size, isDragging, children } = props;

const theme = useTheme();

return (
<div css={S.body({ theme, currentY, size, isDragging })}>{children}</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { css } from '@emotion/react';

export const container = css`
position: fixed;
z-index: 1;
inset: 0;

display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;

width: 100%;
height: 100%;
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { PropsWithChildren } from 'react';
import * as S from './BottomSheetContainer.style';

export default function BottomSheetContainer(props: PropsWithChildren) {
const { children } = props;

return <div css={S.container}>{children}</div>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { css } from '@emotion/react';

export const handleWrapper = css`
display: flex;
align-items: center;
justify-content: center;
height: 32px;
`;

export const handleBar = css`
width: 50px;
height: 6px;
background-color: black;
border-radius: 12px;
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import * as S from './BottomSheetHandle.style';

interface BottomSheetHandleProps {
onTouchStart: (event: React.TouchEvent) => void;
onTouchMove: (event: React.TouchEvent) => void;
onTouchEnd: () => void;
}

export default function BottomSheetHandle(props: BottomSheetHandleProps) {
const { onTouchStart, onTouchMove, onTouchEnd } = props;

return (
<div
css={S.handleWrapper}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
>
<div css={S.handleBar} />
</div>
);
}
22 changes: 22 additions & 0 deletions frontend/src/components/CloseButton/CloseButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import CloseIcon from '@_components/Icons/CloseIcon';
import { css } from '@emotion/react';
import { ButtonHTMLAttributes } from 'react';

interface CloseButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {}

export default function CloseButton(props: CloseButtonProps) {
const { ...rest } = props;

return (
<button
css={css`
padding: 0.4rem;
background: none;
border: none;
`}
{...rest}
>
<CloseIcon />
</button>
);
}
16 changes: 16 additions & 0 deletions frontend/src/components/Dimmer/Dimmer.style.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { css } from '@emotion/react';

export const dimmer = css`
position: fixed;
inset: 0;

display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;

width: 100%;
height: 100%;

background-color: rgb(0 0 0 / 20%);
`;
5 changes: 5 additions & 0 deletions frontend/src/components/Dimmer/Dimmer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import * as S from './Dimmer.style';

export default function Dimmer({ onClick }: { onClick: () => void }) {
return <div onClick={onClick} css={S.dimmer} />;
}
Loading
Loading