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

[Feature] - 마이 페이지 구현 #236

Merged
merged 35 commits into from
Aug 7, 2024
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
907e35a
feat: Tab 컴포넌트에 새로고침해도 선택된 tab 그대로이도록 수정
0jenn0 Aug 4, 2024
e1a7409
feat: AvatarCircle에 medium 사이즈 추가
0jenn0 Aug 4, 2024
b916835
test: AvatarCircle의 medium 사이즈 storybook 추가
0jenn0 Aug 4, 2024
b2ef02d
feat: 마이페이지 route 설정
0jenn0 Aug 4, 2024
d9e14a8
feat: Header Drawer에 마이페이지 route 처리
0jenn0 Aug 4, 2024
6b780c4
feat: 마이페이지 tab content에서 사용될 TabContent 컴포넌트 구현
0jenn0 Aug 4, 2024
6c6695c
fix: Tab 컴포넌트 unmount 될 때 선택된 탭이 첫번째 탭이 되도록 수정
0jenn0 Aug 4, 2024
fcd53a8
style: 필요없는 주석 삭제
0jenn0 Aug 4, 2024
3352e04
refactor: Tab 컴포넌트에 새로고침할 때 처음 버튼으로 돌아갔다가 최근껄로 보이는거 수정
0jenn0 Aug 5, 2024
536ee5d
feat: useInfiniteMyTravelPlans 구현
0jenn0 Aug 6, 2024
3b997d3
fix: renderItem type 수정
0jenn0 Aug 6, 2024
81877fe
feat: 날짜 문자열에 숫자로 일수 더해주는 util 함수 구현
0jenn0 Aug 6, 2024
1838659
fix: TravelPlanDetail 페이지를 useGetTravelPlan 반환 타입에 맞춰 수정
0jenn0 Aug 6, 2024
3c981b5
feat: MyTravelPlans 컴포넌트 구현
0jenn0 Aug 6, 2024
ea86c3a
feat: MyTravelogues 컴포넌트 구현
0jenn0 Aug 6, 2024
46ef7eb
feat: 마이 페이지 구현
0jenn0 Aug 6, 2024
b96c57b
Merge branch 'develop/fe' into feature/fe/#194
0jenn0 Aug 6, 2024
267c5f8
fix: 쓰지 않는 변수들 삭제
0jenn0 Aug 6, 2024
794de85
fix: type 이름 수정
0jenn0 Aug 6, 2024
e6d8c7d
fix: 여행기 등록페이지에서 type 맞춰서 수정
0jenn0 Aug 6, 2024
0f321c5
refactor: Tab 컴포넌트 상수 분리
0jenn0 Aug 7, 2024
117fe4b
refactor: state 초기값을 localStorage에서 가져오도록 수정
0jenn0 Aug 7, 2024
f872f57
style: border css 순서 수정
0jenn0 Aug 7, 2024
760705a
style: 색상 코드 하드코딩 대신 디자인 토큰값 사용
0jenn0 Aug 7, 2024
1ecb243
refactor: MyPage에서 필요없는 useEffect 제거
0jenn0 Aug 7, 2024
b74a499
style: font-weight값 수정
0jenn0 Aug 7, 2024
c1fac1d
style: 긴 css를 styled.ts로 분리
0jenn0 Aug 7, 2024
2649e6f
refactor: TabContent 컴포넌트 이름을 MyPageTapContent로 수정
0jenn0 Aug 7, 2024
937d37c
refactor: MyTravelogue 타입을 types폴더로 이동
0jenn0 Aug 7, 2024
14f500c
refactor: MyPageTabContent의 props명 수정
0jenn0 Aug 7, 2024
a0d5d6c
refactor: queries에서 try catch 제거
0jenn0 Aug 7, 2024
3d4a5b8
refactor: data 평탄화를 select로 리팩토링
0jenn0 Aug 7, 2024
31614a6
fix: useGetTravelPlan에서 id string만 받도록 수정
0jenn0 Aug 7, 2024
2b16133
Merge branch 'develop/fe' of https://github.com/woowacourse-teams/202…
jinyoung234 Aug 7, 2024
e9f82ce
refactor: queryKey 수정
jinyoung234 Aug 7, 2024
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
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const meta = {
$size: {
control: {
type: "select",
options: ["small", "large"],
options: ["small", "medium", "large"],
},
},
profileImageUrl: { control: "text" },
Expand All @@ -36,6 +36,13 @@ export const Small: Story = {
},
};

export const Medium: Story = {
args: {
$size: "medium",
profileImageUrl: "https://i.pinimg.com/564x/c0/d6/5e/c0d65ef2ff5b3e752b70fe54d94d6206.jpg",
},
};

export const Large: Story = {
args: {
$size: "large",
Expand All @@ -50,6 +57,13 @@ export const WithDefaultAvatar: Story = {
},
};

export const MediumWithDefaultAvatar: Story = {
args: {
$size: "medium",
profileImageUrl: "https://invalid-image-url.jpg",
},
};

export const LargeWithDefaultAvatar: Story = {
args: {
$size: "large",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,26 @@ import styled from "@emotion/styled";
import type { AvatarCircleSize } from "./AvatarCircle.type";

const getSize = ($size: AvatarCircleSize) => {
return $size === "small" ? "2.2rem" : "12.9rem";
if ($size === "small") return "2.2rem";
if ($size === "medium") return "6rem";
if ($size === "large") return "12.9rem";
};

const getIconSize = ($size: AvatarCircleSize) => {
return $size === "small" ? "1.5rem" : "10rem";
if ($size === "small") return "1.5rem";
if ($size === "medium") return "5rem";
if ($size === "large") return "10rem";
Comment on lines +6 to +14
Copy link
Contributor

Choose a reason for hiding this comment

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

이 부분 switch 문을 사용해도 좋을 거 같아요 아니면

const avatarCircleSize = {
small: css`2.2rem`;
}

이런식으로 하는 것도 좋아보이네요! 또 css로 해주는게 좀 더 의미 전달이 명확하다는 피드백을 받았는데 참고해보고 판단하면 될 것 같습니다!

};

export const FallbackIcon = styled.div<{ $size: AvatarCircleSize }>`
display: flex;
justify-content: center;
align-items: center;
position: relative;
width: 100%;
height: 100%;

background-color: #d9d9d9;
justify-content: center;
align-items: center;

svg {
position: absolute;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,16 @@ import type { AvatarCircleSize } from "./AvatarCircle.type";
interface AvatarCircleProps {
$size?: AvatarCircleSize;
profileImageUrl?: string;
imageAlt?: string;
}

const AvatarCircle = ({ $size = "small", profileImageUrl }: AvatarCircleProps) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

$ prefix는 @emotion/styled를 사용하는 영역에서 필요한 것이라고 이해했는데 <S.FallbackIcon $size={size}>의 형태가 되어야 하지 않을까 싶습니다!

const AvatarCircle = ({ $size = "small", profileImageUrl, imageAlt }: AvatarCircleProps) => {
const { imageError, handleImageError } = useImageError({ imageUrl: profileImageUrl });

return (
<S.AvatarCircleContainer $size={$size}>
{!imageError ? (
<img src={profileImageUrl} alt="사용자 프로필 이미지" onError={handleImageError} />
<img src={profileImageUrl} alt={imageAlt} onError={handleImageError} />
) : (
<S.FallbackIcon $size={$size}>
<svg
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export type AvatarCircleSize = "small" | "large";
export type AvatarCircleSize = "small" | "medium" | "large";
5 changes: 3 additions & 2 deletions frontend/src/components/common/Header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ const Header = () => {
? () => navigate(ROUTE_PATHS_MAP.root)
: () => navigate(ROUTE_PATHS_MAP.back);

const handleClickMyPage = () => navigate(ROUTE_PATHS_MAP.my);

const { user } = useUser();

return (
Expand Down Expand Up @@ -57,8 +59,7 @@ const Header = () => {
<Drawer.Content>
<S.MenuList>
<Drawer.Trigger>
{/* TODO: 마이페이지 로직 필요함 */}
<S.MenuItem>마이페이지</S.MenuItem>
<S.MenuItem onClick={handleClickMyPage}>마이페이지</S.MenuItem>
</Drawer.Trigger>
<Drawer.Trigger>
{user ? (
Expand Down
30 changes: 24 additions & 6 deletions frontend/src/components/common/Tab/Tab.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,30 @@
import { useRef, useState } from "react";
import { ComponentPropsWithoutRef, useLayoutEffect, useRef, useState } from "react";

import { STORAGE_KEYS_MAP } from "@constants/storage";

import * as S from "./Tab.styled";

interface TabProps {
interface TabProps extends React.PropsWithChildren<ComponentPropsWithoutRef<"ul">> {
tabContent: (selectedIndex: number) => JSX.Element;
labels: string[];
}

const Tab = ({ labels, tabContent }: TabProps) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const Tab = ({ labels, tabContent, ...props }: TabProps) => {
const FIRST_TAB_INDEX = 0;
const INITIAL_START_X = 0;
Copy link
Contributor

Choose a reason for hiding this comment

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

Tab 컴포넌트가 리렌더링이 되면 함수를 다시 실행하게 됩니다.

즉, 컴포넌트 내부 변수들이 다시 재할당되기 때문에 가비지컬렉팅이 되더라도 바로 되지 않기 때문에 메모리 상으로 안 좋다고 들었습니다!

그래서 상수 값들은 컴포넌트 내 constants 파일로 따로 분리하는게 좋다고 생각합니다!

const INITIAL_SCROLL_LEFT = 0;

const [selectedIndex, setSelectedIndex] = useState(FIRST_TAB_INDEX);

const tabRefs = useRef<(HTMLLIElement | null)[]>([]);
const tabListRef = useRef<HTMLUListElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [startX, setStartX] = useState(0);
const [scrollLeft, setScrollLeft] = useState(0);
const [startX, setStartX] = useState(INITIAL_START_X);
const [scrollLeft, setScrollLeft] = useState(INITIAL_SCROLL_LEFT);

const handleClickTab = (index: number) => {
setSelectedIndex(index);
localStorage.setItem(STORAGE_KEYS_MAP.myPageSelectedTab, JSON.stringify(index));
};

const handleMouseDown = (e: React.MouseEvent) => {
Expand Down Expand Up @@ -46,6 +53,16 @@ const Tab = ({ labels, tabContent }: TabProps) => {
}
};

useLayoutEffect(() => {
const currentSelectedTabIndex = JSON.parse(
Copy link
Contributor

Choose a reason for hiding this comment

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

이 훅은 처음 보는 훅인데 혹시 useEffect가 아니라 이 훅을 쓴 이유가 있을까요?

Copy link
Contributor

Choose a reason for hiding this comment

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

 const [selectedIndex, setSelectedIndex] = useState(() =>
    JSON.parse(
      localStorage.getItem(STORAGE_KEYS_MAP.myPageSelectedTab) ?? FIRST_TAB_INDEX.toString(),
    ),
  );

LayoutEffect 대신 state 초깃값으로 다음과 같이 설정해도 동일하게 동작해서 LayoutEffect는 제거해도 될 거 같아요!

LayoutEffect는 주로 DOM에 commit하기 전 레이아웃을 계산하는 용도로 많이 사용되니 참고하시면 좋을거 같아요!

https://ko.react.dev/reference/react/useLayoutEffect

localStorage.getItem(STORAGE_KEYS_MAP.myPageSelectedTab) ?? FIRST_TAB_INDEX.toString(),
);
setSelectedIndex(currentSelectedTabIndex);

return () =>
localStorage.setItem(STORAGE_KEYS_MAP.myPageSelectedTab, JSON.stringify(FIRST_TAB_INDEX));
}, []);

return (
<>
<S.TabList
Expand All @@ -54,6 +71,7 @@ const Tab = ({ labels, tabContent }: TabProps) => {
onMouseLeave={handleMouseLeave}
onMouseUp={handleMouseUp}
onMouseMove={handleMouseMove}
{...props}
>
{labels.map((label, index) => (
<S.TabItem
Expand Down
37 changes: 37 additions & 0 deletions frontend/src/components/pages/my/MyPage.styled.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { css } from "@emotion/react";
import styled from "@emotion/styled";

import theme from "@styles/theme";

export const Layout = styled.div`
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
gap: ${(props) => props.theme.spacing.xl};
padding: ${(props) => props.theme.spacing.l};
`;

export const BoxButton = styled.button`
display: flex;
gap: ${(props) => props.theme.spacing.m};
justify-content: flex-start;
align-items: center;
width: 100%;
padding: ${(props) => props.theme.spacing.m};
border: solid 1px ${(props) => props.theme.colors.border};
Copy link
Contributor

Choose a reason for hiding this comment

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

사소하긴한데 1px solid color 식이면 더 좋을 거 같아요!

border-radius: 10px;
`;

export const ColorButtonStyle = css`
display: flex;
justify-content: flex-start;
align-items: center;
width: 100%;
height: 6.8rem;
padding: ${theme.spacing.m};
border-radius: 10px;

background-color: #f3faff;
Copy link
Contributor

Choose a reason for hiding this comment

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

theme에 blue 50으로 적용하면 좋을거 같아요 :)

gap: ${theme.spacing.m};
`;
59 changes: 59 additions & 0 deletions frontend/src/components/pages/my/MyPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";

import { css } from "@emotion/react";

import { AvatarCircle, Tab, Text } from "@components/common";

import useUser from "@hooks/useUser";

import { ERROR_MESSAGE_MAP } from "@constants/errorMessage";
import { ROUTE_PATHS_MAP } from "@constants/route";

import theme from "@styles/theme";

import * as S from "./MyPage.styled";
import MyTravelPlans from "./MyTravelPlans/MyTravelPlans";
import MyTravelogues from "./MyTravelogues/MyTravelogues";

const MyPage = () => {
const navigate = useNavigate();

const { user } = useUser();

useEffect(() => {
Copy link
Contributor

Choose a reason for hiding this comment

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

이미 authClient에서 처리하는 로직이라 없어도 될거 같아요!

if (!user?.accessToken) {
alert(ERROR_MESSAGE_MAP.api.login);
navigate(ROUTE_PATHS_MAP.login);
}
}, [user?.accessToken, navigate]);

return (
<S.Layout>
<AvatarCircle $size="large" profileImageUrl={user?.profileImageUrl} />
<Text
textType="body"
css={css`
Copy link
Contributor

Choose a reason for hiding this comment

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

700으로 통일해주시면 좋을거 같슴다

font-weight: 600;
`}
>
{user?.nickname}
</Text>

<Tab
Copy link
Contributor

Choose a reason for hiding this comment

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

제가 render props로 바꿔놨는데도 잘 활용해주셨네요 :)

labels={["내 여행 계획", "내 여행기"]}
tabContent={(selectedIndex) => (
<>{selectedIndex === 0 ? <MyTravelPlans /> : <MyTravelogues />}</>
)}
Comment on lines +28 to +30
Copy link
Contributor

Choose a reason for hiding this comment

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

엄청 깔끔하네요!

css={css`
li {
${theme.typography.mobile.body};
font-weight: 600;
}
`}
Copy link
Contributor

Choose a reason for hiding this comment

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

css 부분이 조금 길어진다면 styled 파일에 따로 분리해보는 것도 좋을 것 같아요!

/>
</S.Layout>
);
};

export default MyPage;
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { css } from "@emotion/react";
import styled from "@emotion/styled";

import theme from "@styles/theme";

export const Layout = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
gap: ${(props) => props.theme.spacing.s};
width: 100%;
`;

export const Container = styled.div`
display: flex;
flex-direction: column;
align-items: flex-start;
gap: ${(props) => props.theme.spacing.s};
`;

export const iconButtonStyle = css`
display: flex;
gap: ${theme.spacing.s};
padding: ${theme.spacing.m};
border: 1px solid ${theme.colors.border};
border-radius: 10px;
${theme.typography.mobile.detail};
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { useNavigate } from "react-router-dom";

import { css } from "@emotion/react";

import { useTravelTransformDetailContext } from "@contexts/TravelTransformDetailProvider";
import useInfiniteMyTravelPlans from "@queries/useInfiniteMyTravelPlans";
import addDaysToDateString from "@utils/addDaysToDateString";

import { IconButton, Text } from "@components/common";

import useIntersectionObserver from "@hooks/useIntersectionObserver";

import { ROUTE_PATHS_MAP } from "@constants/route";

import theme from "@styles/theme";

import TabContent from "../TabContent/TabContent";
import * as S from "./MyTravelPlans.styled";

const MyTravelPlans = () => {
const { onTransformTravelDetail } = useTravelTransformDetailContext();
const navigate = useNavigate();

const { myTravelPlans, status, fetchNextPage } = useInfiniteMyTravelPlans();
const { lastElementRef } = useIntersectionObserver(fetchNextPage);

const handleClickAddButton = () => {
navigate(ROUTE_PATHS_MAP.travelPlanRegister);
};

const handleClickTravelPlan = (id: string) => {
navigate(ROUTE_PATHS_MAP.travelPlan(Number(id)));
};

if (status === "pending") return <div>로딩 중...</div>;

return (
<>
<TabContent
iconButtonLabel="새 여행 계획 추가하기"
onClickIconButton={handleClickAddButton}
data={myTravelPlans}
Copy link
Contributor

Choose a reason for hiding this comment

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

TabContent라는 네이밍 보단 MyPageTabContent로, data 보단 contentDetail 정도의 네이밍이 어떨까요 .. ? 이미 TabContent는 다른 페이지에서도 ~TabContent라는 네이밍을 사용 중이기도 해서 헷갈릴거 같아요 🥲

renderItem={({ id, title, startDate, days }) => (
<S.Layout onClick={() => handleClickTravelPlan(id)}>
<S.Container>
<Text
textType="body"
css={css`
font-weight: 500;
`}
>
{title}
</Text>
<Text
textType="detail"
css={css`
color: ${theme.colors.text.secondary};
`}
>
{days.length !== 1
? `${startDate} - ${addDaysToDateString({
Copy link
Contributor

Choose a reason for hiding this comment

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

요녀석은 아래와 같이 변수로 분리해보는게 어떨까요?!

const dateRange = days.length > 1
  ? `${startDate} - ${getEndDate(startDate, days.length)}`
  : startDate;

dateString: startDate,
daysToAdd: days.length - 1,
})}`
: `${startDate}`}
</Text>
</S.Container>

<IconButton
onClick={(e) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

handleTransformTravelogue로 분리해도 좋을거 같아요

e.stopPropagation();
onTransformTravelDetail(ROUTE_PATHS_MAP.travelogueRegister, { days: days ?? [] });
}}
size="16"
position="left"
iconType="plus"
css={S.iconButtonStyle}
>
여행기로 전환
</IconButton>
</S.Layout>
)}
/>
<div
ref={lastElementRef}
css={css`
height: 1px;
`}
/>
</>
);
};

export default MyTravelPlans;
Loading
Loading