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] 작성한 리뷰를 확인할 수 있는 반응형 레이아웃 #1038

Open
wants to merge 20 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ea223e3
feat: 현재 미디어 쿼리 상태와 디바이스 종류(boolean)를 리턴하는 훅
ImxYJL Dec 30, 2024
437ba4c
feat: 작성한 리뷰 페이지의 분할 레이아웃을 담당하는 WrittenReviewItem 레이아웃 컴포넌트
ImxYJL Dec 30, 2024
b50b5f1
feat: 임시 WrittenReviewList 컴포넌트
ImxYJL Dec 30, 2024
13ca3f9
feat: 임시 DetailedWrittenReview 컴포넌트
ImxYJL Dec 30, 2024
e8dcc78
feat: 임시 작성한 리뷰 확인 페이지
ImxYJL Dec 30, 2024
a8d2246
feat: 작성한 리뷰 페이지에 대한 임시 라우팅
ImxYJL Dec 30, 2024
eb106d6
feat: 임시 레이아웃, 반응형 적용
ImxYJL Jan 2, 2025
4474aeb
feat: 선택한 리뷰가 없을 때의 컴포넌트 추가
ImxYJL Jan 2, 2025
e3a57a1
refactor: 페이지 레이아웃 이름을 더 직관적이고 단순하게 수정
ImxYJL Jan 4, 2025
5f719b0
chore: WrittenReviewPage의 layout 폴더 위치를 component 하위로 변경
ImxYJL Jan 5, 2025
f2073d4
refactor: 작성한 리뷰 확인 페이지의 이름을 WrittenReviewPage로 간략하게 변경
ImxYJL Jan 9, 2025
117735a
refactor: 반응형 레이아웃을 위해 queryString 도입 (+변경된 페이지명에 따른 추가 변경사항)
ImxYJL Jan 9, 2025
aa8344a
chore: amplitude 페이지 정보에 작성한 리뷰 확인 페이지 추가
ImxYJL Jan 9, 2025
2b0aa9d
refactor: 작성한 리뷰 확인 페이지에 early return 스타일 적용
ImxYJL Jan 9, 2025
d3eb2d5
refactor: useSearchParamAndQuery의 매개변수 paramKey를 optional로 변경
ImxYJL Jan 9, 2025
ff3e520
refactor: 미디어 쿼리 관련 훅 리팩토링 - mediaType 대신 breakpoint로 명시
ImxYJL Jan 9, 2025
b0e6234
chore: 간단한 변수명 수정
ImxYJL Jan 9, 2025
a15cd98
chore: Breakpoints 타입 분리
ImxYJL Jan 9, 2025
aee47a5
chore: 경로 수정
ImxYJL Jan 9, 2025
699dde2
refactor: resize 함수에 debounce 추가
ImxYJL Jan 9, 2025
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
4 changes: 4 additions & 0 deletions frontend/src/assets/slideArrows.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions frontend/src/components/ReviewListItem/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// 임시 컴포넌트! 작성한 리뷰 확인 && 받은 리뷰 확인 아이템

import * as S from './styles';

interface ReviewListItemProps {
handleClick: () => void;
}

const ReviewListItem = ({ handleClick }: ReviewListItemProps) => {
return <S.ReviewListItem onClick={handleClick}>리뷰 목록 아이템입니다</S.ReviewListItem>;
};

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

import media from '@/utils/media';

export const ReviewListItem = styled.li`
display: flex;
flex-direction: column;

min-width: ${({ theme }) => theme.writtenReviewLayoutSize.width};
min-height: 20rem;

border: 0.2rem solid ${({ theme }) => theme.colors.placeholder};
border-radius: ${({ theme }) => theme.borderRadius.basic};

${media.small} {
min-width: 30rem;
min-height: 18rem;
}
`;
1 change: 1 addition & 0 deletions frontend/src/constants/amplitudeEventName.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const PAGE_VISITED_EVENT_NAME: { [key in Exclude<PageName, undefined>]: s
detailedReview: '[page] 리뷰 상세 보기 페이지',
reviewWriting: '[page] 리뷰 작성 페이지',
reviewWritingComplete: '[page] 리뷰 작성 완료 페이지',
writtenReview: '[page] 작성한 리뷰 확인 페이지',
};

export const REVIEW_WRITING_EVENT_NAME = {
Expand Down
1 change: 1 addition & 0 deletions frontend/src/constants/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ export const ROUTE = {
detailedReview: 'user/detailed-review',
reviewZone: 'user/review-zone',
reviewCollection: 'user/review-collection',
writtenReview: 'user/written-review',
};
4 changes: 2 additions & 2 deletions frontend/src/hooks/useSearchParamAndQuery.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { useLocation, useParams } from 'react-router';

interface UseSearchParamAndQueryProps {
paramKey: string;
paramKey?: string;
queryStringKey?: string;
}
/**
* url에서 원하는 param, queryString의 값을 가져온다.
* @param paramKey: 가져오고 싶은 param의 key
* @param queryStringKey: 가져오고 싶은 queryString의 key (옵셔널)
* @param queryStringKey: 가져오고 싶은 queryString의 key
*/
const useSearchParamAndQuery = ({ paramKey, queryStringKey }: UseSearchParamAndQueryProps) => {
const location = useLocation();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { NoSelectedReviewGuide } from '../index';
import { PageContentLayout } from '../layouts';

import * as S from './styles';

interface DetailedWrittenReviewProps {
$isMobile: boolean;
selectedReviewId: number | null;
}

// 라우팅으로 들어오는 경우 queryParam으로 reviewId를 가져올 수 있음
// -> 그렇다면 라우터에서 이 컴포넌트를 별도의 props 없이 호출 가능, selectedReviewId는 optional 처리
// but 일단은 props로 id를 무조건 받도록 구현해둔 상태
const DetailedWrittenReview = ({ $isMobile, selectedReviewId }: DetailedWrittenReviewProps) => {
// 추후 이곳에서 직접 상세 리뷰 데이터 호출

// 라우팅으로 넘어온 경우 무조건 isMobile은 true
return (
<S.DetailedWrittenReview $isMobile={$isMobile}>
<PageContentLayout title="작성한 리뷰 상세보기">
<S.Outline>{selectedReviewId ? <div>{selectedReviewId} 선택함 </div> : <NoSelectedReviewGuide />}</S.Outline>
</PageContentLayout>
</S.DetailedWrittenReview>
);
};

export default DetailedWrittenReview;
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import styled from '@emotion/styled';

import media from '@/utils/media';

interface DetailedWrittenReviewStyleProps {
$isMobile: boolean;
}

export const DetailedWrittenReview = styled.div<DetailedWrittenReviewStyleProps>`
${media.xSmall} {
${({ $isMobile }) =>
$isMobile
? `
display: block;
width: 100%;
`
: `
display: none;
`}
}
`;

export const Outline = styled.div`
display: flex;
align-items: center;

min-width: ${({ theme }) => theme.writtenReviewLayoutSize.width};
min-height: ${({ theme }) => theme.writtenReviewLayoutSize.height};

border: 0.2rem solid ${({ theme }) => theme.colors.lightGray};
border-radius: ${({ theme }) => theme.borderRadius.basic};
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import SlideArrowsIcon from '@/assets/slideArrows.svg';

import * as S from './styles';

const NoSelectedReviewGuide = () => {
return (
<S.NoSelectedReview>
<img src={SlideArrowsIcon} alt="" />
<p>확인할 리뷰를 선택해주세요!</p>
</S.NoSelectedReview>
);
};

export default NoSelectedReviewGuide;
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import styled from '@emotion/styled';

import media from '@/utils/media';

export const NoSelectedReview = styled.section`
display: flex;
gap: 2rem;
align-items: center;
justify-content: center;

margin: 0 auto;

img {
height: 3rem;

${media.medium} {
height: 2.8rem;
margin-left: 2.5rem;
}
}

p {
font-size: ${({ theme }) => theme.fontSize.mediumSmall};
font-weight: bold;
color: ${({ theme }) => theme.colors.disabled};

${media.medium} {
font-size: ${({ theme }) => theme.fontSize.basic};
}
}
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import ReviewListItem from '@/components/ReviewListItem';

import { PageContentLayout } from '../layouts';

import * as S from './styles';

interface WrittenReviewListProps {
handleClick: (reviewId: number) => void;
}

const WrittenReviewList = ({ handleClick }: WrittenReviewListProps) => {
// 리뷰 리스트 받아오기
const reviewIdList = [5, 1, 2, 3, 4];

return (
<PageContentLayout title="작성한 리뷰 목록">
<S.WrittenReviewList>
{/** 추후 이벤트 위임 형식으로 변경 가능 */}

{/** TODO: 작성한 리뷰 없을 때의 컴포넌트 추가*/}
{reviewIdList.map((reviewId) => (
<ReviewListItem key={reviewId} handleClick={() => handleClick(reviewId)} />
))}
</S.WrittenReviewList>
</PageContentLayout>
);
};

export default WrittenReviewList;
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import styled from '@emotion/styled';

import media from '@/utils/media';


export const WrittenReviewList = styled.ul`
overflow-x: hidden;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1.7rem;

max-height: 68vh;

${media.xSmall} {
width: 100%;
}

& > li {
margin-right: 0.5rem;
}
`;
3 changes: 3 additions & 0 deletions frontend/src/pages/WrittenReviewPage/components/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { default as NoSelectedReviewGuide } from './NoSelectedReviewGuide';
export { default as DetailedWrittenReview } from './DetailedWrittenReview';
export { default as WrittenReviewList } from './WrittenReviewList';
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { EssentialPropsWithChildren } from '@/types';

import * as S from './styles';

interface WrittenReviewItemProps {
title: string;
}

const PageContentLayout = ({ title, children }: EssentialPropsWithChildren<WrittenReviewItemProps>) => {
return (
<S.PageContentLayout>
<S.Title>{title}</S.Title>
<S.Content>{children}</S.Content>
</S.PageContentLayout>
);
};

export default PageContentLayout;
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import styled from '@emotion/styled';

import media from '@/utils/media';

export const PageContentLayout = styled.article`
display: flex;
flex-direction: column;
height: 100%;

${media.xSmall} {
margin: 0 auto;
}
`;

export const Title = styled.h2`
margin-top: 4.7rem;
margin-bottom: 2.4rem;
font-size: 1.8rem;
font-weight: bold;
`;

export const Content = styled.section`

`;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as PageContentLayout } from './PageContentLayout';
1 change: 1 addition & 0 deletions frontend/src/pages/WrittenReviewPage/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as useDeviceBreakpoints } from './useDeviceBreakpoints';
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { useState, useLayoutEffect } from 'react';

import { breakpoint } from '@/styles/theme';
import { Breakpoints } from '@/types/media';
import { debounce } from '@/utils';

interface CurrentDevice {
isMobile: boolean;
isTablet: boolean;
isDesktop: boolean;
}

const DEBOUNCE_TIME = 100;

/**
현재 미디어 쿼리 상태와 디바이스 종류(boolean)를 리턴하는 훅
*/
const useDeviceBreakpoints = () => {
const [breakpointType, setBreakPointType] = useState<Breakpoints | null>(null);
const breakpointsArray = Object.entries(breakpoint);

const getDeviceType = (breakpointType: Breakpoints | null): CurrentDevice => ({
isMobile: breakpointType === 'xSmall' || breakpointType === 'xxSmall',
isTablet: breakpointType === 'small' || breakpointType === 'medium',
isDesktop: breakpointType === 'large',
});

const handleResize = debounce(() => {
const currentWidth = window.innerWidth;
const matchedBreakpoint = breakpointsArray.find(([, width]) => currentWidth <= width);

setBreakPointType((matchedBreakpoint?.[0] as Breakpoints) ?? null);
}, DEBOUNCE_TIME);

useLayoutEffect(() => {
handleResize();
window.addEventListener('resize', handleResize);

return () => window.removeEventListener('resize', handleResize);
}, []);

return {
breakpointType,
deviceType: getDeviceType(breakpointType),
};
};

export default useDeviceBreakpoints;
51 changes: 51 additions & 0 deletions frontend/src/pages/WrittenReviewPage/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { useLocation, useNavigate } from 'react-router-dom';

import { ErrorSuspenseContainer, AuthAndServerErrorFallback } from '@/components';
import { useSearchParamAndQuery } from '@/hooks';

import DetailedWrittenReview from './components/DetailedWrittenReview';
import WrittenReviewList from './components/WrittenReviewList';
import { useDeviceBreakpoints } from './hooks';
import * as S from './styles';

const WrittenReviewPage = () => {
const navigate = useNavigate();
const location = useLocation();
const { deviceType } = useDeviceBreakpoints();

const { queryString: reviewIdString } = useSearchParamAndQuery({
queryStringKey: 'reviewId',
});

const selectedReviewId = reviewIdString ? Number(reviewIdString) : null;

const handleReviewItemClick = (reviewId: number) => {
const params = new URLSearchParams();
params.set('reviewId', reviewId.toString());

navigate(`${location.pathname}?${params.toString()}`);
};

const renderContent = () => {
if (deviceType.isMobile) {
// 모바일: queryString 없으면 목록, 있으면 상세보기
return selectedReviewId ? (
<DetailedWrittenReview $isMobile={true} selectedReviewId={selectedReviewId} />
) : (
<WrittenReviewList handleClick={handleReviewItemClick} />
);
}

// 태블릿 ~ : 목록 + 상세보기
return (
<S.PageContainer>
<WrittenReviewList handleClick={handleReviewItemClick} />
<DetailedWrittenReview $isMobile={false} selectedReviewId={selectedReviewId} />
</S.PageContainer>
);
};

return <ErrorSuspenseContainer fallback={AuthAndServerErrorFallback}>{renderContent()}</ErrorSuspenseContainer>;
};

export default WrittenReviewPage;
Loading
Loading