-
Notifications
You must be signed in to change notification settings - Fork 5
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
Changes from 20 commits
907e35a
e1a7409
b916835
b2ef02d
d9e14a8
6b780c4
6c6695c
fcd53a8
3352e04
536ee5d
3b997d3
81877fe
1838659
3c981b5
ea86c3a
46ef7eb
b96c57b
267c5f8
794de85
e6d8c7d
0f321c5
117fe4b
f872f57
760705a
1ecb243
b74a499
c1fac1d
2649e6f
937d37c
14f500c
a0d5d6c
3d4a5b8
31614a6
2b16133
e9f82ce
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -6,15 +6,16 @@ import type { AvatarCircleSize } from "./AvatarCircle.type"; | |
interface AvatarCircleProps { | ||
$size?: AvatarCircleSize; | ||
profileImageUrl?: string; | ||
imageAlt?: string; | ||
} | ||
|
||
const AvatarCircle = ({ $size = "small", profileImageUrl }: AvatarCircleProps) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. $ prefix는 @emotion/styled를 사용하는 영역에서 필요한 것이라고 이해했는데 |
||
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 | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1 @@ | ||
export type AvatarCircleSize = "small" | "large"; | ||
export type AvatarCircleSize = "small" | "medium" | "large"; |
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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) => { | ||
|
@@ -46,6 +53,16 @@ const Tab = ({ labels, tabContent }: TabProps) => { | |
} | ||
}; | ||
|
||
useLayoutEffect(() => { | ||
const currentSelectedTabIndex = JSON.parse( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이 훅은 처음 보는 훅인데 혹시 useEffect가 아니라 이 훅을 쓴 이유가 있을까요? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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하기 전 레이아웃을 계산하는 용도로 많이 사용되니 참고하시면 좋을거 같아요! |
||
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 | ||
|
@@ -54,6 +71,7 @@ const Tab = ({ labels, tabContent }: TabProps) => { | |
onMouseLeave={handleMouseLeave} | ||
onMouseUp={handleMouseUp} | ||
onMouseMove={handleMouseMove} | ||
{...props} | ||
> | ||
{labels.map((label, index) => ( | ||
<S.TabItem | ||
|
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}; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. theme에 blue 50으로 적용하면 좋을거 같아요 :) |
||
gap: ${theme.spacing.m}; | ||
`; |
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(() => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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` | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 700으로 통일해주시면 좋을거 같슴다 |
||
font-weight: 600; | ||
`} | ||
> | ||
{user?.nickname} | ||
</Text> | ||
|
||
<Tab | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 엄청 깔끔하네요! |
||
css={css` | ||
li { | ||
${theme.typography.mobile.body}; | ||
font-weight: 600; | ||
} | ||
`} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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({ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
이 부분 switch 문을 사용해도 좋을 거 같아요 아니면
이런식으로 하는 것도 좋아보이네요! 또 css로 해주는게 좀 더 의미 전달이 명확하다는 피드백을 받았는데 참고해보고 판단하면 될 것 같습니다!