Skip to content

Commit

Permalink
[FE] Refactor/#642: 북마크 및 전체보기 페이지 API 로직 수정 및 React-Query 적용 (#644)
Browse files Browse the repository at this point in the history
* feat: Axios http 정의 및 get 메서드 구현

* refactor: Bookmarks 페이지 TopicCardList 분리 코드 합병

TopicCardList는 재사용을 위해 만들어진 코드이나 Axios, React Query 를 적용하면서 사용하기 애매해졌다.
이에 따라 TopicCardList를 제거하고 각 페이지에서 그 책임을 이어받는다.

* design: Skeleton 컴포넌트 스타일 변경 및 적용

* refactor: SkeletonBox 공통 컴포넌트 구현 및 convertCSS 유틸 함수 구현

* refactor: Skeleton 컴포넌트 Bookmark 페이지에 적용

* refactor: http api 수정 및 useGetBookmark isLoading 상태 사용

* refactor: SeeAllNearTopics 페이지 TopicCardList 분리 및 코드 합병

* refactor: API 로직 반환값 타입 지정

* refactor: Bookmark 스켈레톤 수정

* refactor: SeeAllLatestTopics 페이지 TopicCardList 분리 및 코드 합병

* refactor: SeeAllPopularTopics 페이지 TopicCardList 분리 및 코드 합병

* refactor: AllTopics Query key 수정 및 시맨틱 태그 수정

* rename: API 명세와 페이지 이름 통일화

* refactor: 전체보기 페이지 명칭 수정 router 적용

* feat: 리프레쉬 토큰 요청 기능 추가

운영서버에 머지해야 확인가능할 것 같습니다.
기존 로직을 사용하는 운영서버와 request 값은 동일한데 cookie와 도메인 설정 문제로 실패하는 것으로 확인됩니다.

* feat: query default option 설정

* 마운트 시 리페칭 해제
* 윈도우 포커스 시 리페칭 해제
* 일정 주기로 리페칭 해제
* 받아온 데이터 stale 시 리페칭 해제

* refactor: useSuspenseQuery 를 통한 선언적으로 로딩상태 처리

* fix: token 없을 때 Authorization 빈 객체로 세팅하여 비로그인 오류 해결

* refactor: withCredentials 옵션 잠시 보류

* refactor: 01.17 회의를 통한 변경

* TopicCardList 컴포넌트를 이전처럼 활용하도록 한다. 전체보기 및 즐겨찾기는 거의 동일한 형태이며 중복코드가 다량 발생하여 위와 같이 수정한다.
* url을 넘겨받음에 따라서 리액트 쿼리 훅, API 요청 로직 또한 하나의 훅으로 재사용한다.

---------

Co-authored-by: afds4567 <[email protected]>
  • Loading branch information
semnil5202 and jiwonh423 authored Jan 21, 2024
1 parent 1d5a01c commit f7d8897
Show file tree
Hide file tree
Showing 16 changed files with 293 additions and 176 deletions.
14 changes: 14 additions & 0 deletions frontend/src/apiHooks/new/useGetTopics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { useSuspenseQuery } from '@tanstack/react-query';

import { getTopics } from '../../apis/new';

const useGetTopics = (url: string) => {
const { data: topics, refetch } = useSuspenseQuery({
queryKey: ['topics', url],
queryFn: () => getTopics(url),
});

return { topics, refetch };
};

export default useGetTopics;
101 changes: 101 additions & 0 deletions frontend/src/apis/new/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import axios, {
AxiosInstance,
AxiosRequestConfig,
AxiosRequestHeaders,
} from 'axios';

const API_POSTFIX = 'api';
const BASE_URL = process.env.APP_URL || `https://mapbefine.com/${API_POSTFIX}`;
const token = localStorage.getItem('userToken');

const axiosInstance = axios.create({
baseURL: BASE_URL,
headers: token ? { Authorization: `Bearer ${token}` } : {},
// withCredentials: true,
});

let refreshResponse: Promise<Response> | null = null;

export interface HttpClient extends AxiosInstance {
get<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<T>;
post<T = unknown>(
url: string,
data?: any,
config?: AxiosRequestConfig,
): Promise<T>;
patch<T = unknown>(
url: string,
data?: any,
config?: AxiosRequestConfig,
): Promise<T>;
put<T = unknown>(
url: string,
data?: any,
config?: AxiosRequestConfig,
): Promise<T>;
delete<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<T>;
}

export const http: HttpClient = axiosInstance;

http.interceptors.response.use((res) => res.data);
http.interceptors.request.use(
async (config) => {
const userToken = localStorage.getItem('userToken');

if (userToken && isTokenExpired(userToken)) {
await updateToken(config.headers);
}
return config;
},
(error) => Promise.reject(error),
);

const isTokenExpired = (token: string) => {
const decodedPayloadObject = decodeToken(token);
return decodedPayloadObject.exp * 1000 < Date.now();
};

const decodeToken = (token: string) => {
const tokenParts = token.split('.');

if (tokenParts.length !== 3) {
throw new Error('토큰이 잘못되었습니다.');
}

const decodedPayloadString = atob(tokenParts[1]);

return JSON.parse(decodedPayloadString);
};

async function updateToken(headers: AxiosRequestHeaders) {
const response = await refreshToken(headers);
const responseCloned = response.clone();
const newToken = await responseCloned.json();

localStorage.setItem('userToken', newToken.accessToken);
}

async function refreshToken(headers: AxiosRequestHeaders): Promise<Response> {
if (refreshResponse !== null) {
return refreshResponse;
}

const accessToken = localStorage.getItem('userToken');
refreshResponse = fetch(`${BASE_URL}/refresh-token`, {
method: 'POST',
headers,
body: JSON.stringify({
accessToken,
}),
});

const responseData = await refreshResponse;
refreshResponse = null;

if (!responseData.ok) {
throw new Error('Failed to refresh access token.');
}

return responseData;
}
4 changes: 4 additions & 0 deletions frontend/src/apis/new/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { TopicCardProps } from '../../types/Topic';
import { http } from './http';

export const getTopics = (url: string) => http.get<TopicCardProps[]>(url);
54 changes: 11 additions & 43 deletions frontend/src/components/Skeletons/TopicCardSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -1,53 +1,21 @@
import { keyframes, styled } from 'styled-components';

import Flex from '../common/Flex';
import Box from '../common/Box';
import Space from '../common/Space';
import SkeletonBox from './common/SkeletonBox';

function TopicCardSkeleton() {
return (
<Flex $flexDirection="row">
<SkeletonImg />
<Space size={2} />
<Flex $flexDirection="column">
<SkeletonTitle />
<Space size={5} />
<SkeletonDescription />
</Flex>
</Flex>
<Box>
<SkeletonBox width="100%" $maxWidth={212} ratio="1.6 / 1" />
<Space size={1} />
<SkeletonBox width={212} height={25} />
<Space size={5} />
<SkeletonBox width={100} height={25} />
<Space size={1} />
<SkeletonBox width={212} height={46} />
</Box>
);
}

const skeletonAnimation = keyframes`
from {
opacity: 0.1;
}
to {
opacity: 1;
}
`;

const SkeletonImg = styled.div`
width: 138px;
height: 138px;
border-radius: 8px;
background: ${({ theme }) => theme.color.lightGray};
animation: ${skeletonAnimation} 1s infinite;
`;

const SkeletonTitle = styled.div`
width: 172px;
height: 32px;
border-radius: 8px;
background: ${({ theme }) => theme.color.lightGray};
animation: ${skeletonAnimation} 1s infinite;
`;

const SkeletonDescription = styled(SkeletonTitle)`
height: 80px;
`;

export default TopicCardSkeleton;
40 changes: 27 additions & 13 deletions frontend/src/components/Skeletons/TopicListSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,40 @@
import { styled } from 'styled-components';

import Space from '../common/Space';
import SkeletonBox from './common/SkeletonBox';
import TopicCardSkeleton from './TopicCardSkeleton';

function TopicCardContainerSkeleton() {
function TopicListSkeleton() {
return (
<Wrapper>
<TopicCardSkeleton />
<TopicCardSkeleton />
<TopicCardSkeleton />
<TopicCardSkeleton />
<TopicCardSkeleton />
<TopicCardSkeleton />
</Wrapper>
<>
<Space size={5} />
<SkeletonBox width={160} height={32} />
<Space size={4} />
<Space size={5} />
<TopicCardWrapper>
<TopicCardSkeleton />
<TopicCardSkeleton />
<TopicCardSkeleton />
<TopicCardSkeleton />
<TopicCardSkeleton />
</TopicCardWrapper>
<Space size={4} />
<TopicCardWrapper>
<TopicCardSkeleton />
<TopicCardSkeleton />
<TopicCardSkeleton />
<TopicCardSkeleton />
<TopicCardSkeleton />
</TopicCardWrapper>
</>
);
}

const Wrapper = styled.section`
const TopicCardWrapper = styled.section`
display: flex;
flex-wrap: wrap;
gap: 20px;
width: 1036px;
height: 300px;
width: 1140px;
`;

export default TopicCardContainerSkeleton;
export default TopicListSkeleton;
38 changes: 38 additions & 0 deletions frontend/src/components/Skeletons/common/SkeletonBox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import styled, { keyframes } from 'styled-components';

import { convertCSS } from '../../../utils/convertCSS';

interface Props {
width?: number | string;
height?: number | string;
$maxWidth?: number | string;
$maxHeight?: number | string;
ratio?: string;
radius?: number | string;
}

const skeletonAnimation = keyframes`
from {
opacity: 1;
}
50% {
opacity: 0.6;
}
to {
opacity: 1;
}
`;

const SkeletonBox = styled.div<Props>`
width: ${({ width }) => width && convertCSS(width)};
height: ${({ height }) => height && convertCSS(height)};
max-width: ${({ $maxWidth }) => $maxWidth && convertCSS($maxWidth)};
max-height: ${({ $maxHeight }) => $maxHeight && convertCSS($maxHeight)};
aspect-ratio: ${({ ratio }) => ratio};
border-radius: ${({ radius, theme }) =>
(radius && convertCSS(radius)) || theme.radius.small};
background: ${({ theme }) => theme.color.lightGray};
animation: ${skeletonAnimation} 1s infinite;
`;

export default SkeletonBox;
47 changes: 14 additions & 33 deletions frontend/src/components/TopicCardList/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { useEffect, useState } from 'react';
import { styled } from 'styled-components';

import { TopicCardProps } from '../../types/Topic';
import { ReactNode } from 'react';
import useGetTopics from '../../apiHooks/new/useGetTopics';
import Button from '../common/Button';
import Flex from '../common/Flex';
import Grid from '../common/Grid';
Expand All @@ -12,30 +10,26 @@ import useProfileList from '../../hooks/queries/useProfileList';

interface TopicCardListProps {
url: string;
errorMessage: string;
commentWhenEmpty: string;
pageCommentWhenEmpty: string;
routePageName: string;
routePage: () => void;
children?: React.ReactNode;
svgElementWhenEmpty?: ReactNode;
}

function TopicCardList({
url,
errorMessage,
commentWhenEmpty,
pageCommentWhenEmpty,
routePageName,
routePage,
children,
svgElementWhenEmpty,
}: TopicCardListProps) {
const { data: topics, refetch: refetchTopic } = useProfileList();

if (!topics) return null;
const { topics, refetch } = useGetTopics(url);

if (topics.length === 0) {
return (
<EmptyWrapper>
<Flex height="240px" $flexDirection="column" $alignItems="center">
<Flex $alignItems="center">
{children}
{svgElementWhenEmpty}
<Space size={1} />
<Text color="black" $fontSize="default" $fontWeight="normal">
{commentWhenEmpty}
Expand All @@ -44,14 +38,14 @@ function TopicCardList({
</Flex>
<Space size={5} />
<Button variant="primary" onClick={routePage}>
{pageCommentWhenEmpty}
{routePageName}
</Button>
</EmptyWrapper>
</Flex>
);
}

return (
<Wrapper>
<Flex $flexWrap="wrap" $gap="20px">
<Grid
rows="auto"
columns={5}
Expand All @@ -77,26 +71,13 @@ function TopicCardList({
bookmarkCount={topic.bookmarkCount}
isInAtlas={topic.isInAtlas}
isBookmarked={topic.isBookmarked}
getTopicsFromServer={refetchTopic}
getTopicsFromServer={refetch}
/>
</ul>
))}
</Grid>
</Wrapper>
</Flex>
);
}

const EmptyWrapper = styled.section`
height: 240px;
display: flex;
flex-direction: column;
align-items: center;
`;

const Wrapper = styled.section`
display: flex;
flex-wrap: wrap;
gap: 20px;
`;

export default TopicCardList;
12 changes: 12 additions & 0 deletions frontend/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import ReactDOM from 'react-dom/client';
import ReactGA from 'react-ga4';
import { ThemeProvider } from 'styled-components';
Expand All @@ -9,6 +10,17 @@ import GlobalStyle from './GlobalStyle';
import NotFound from './pages/NotFound';
import theme from './themes';

const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchInterval: false,
refetchOnReconnect: false,
},
},
});

const rootElement = document.getElementById('root');
if (!rootElement) throw new Error('Failed to find the root element');
const root = ReactDOM.createRoot(rootElement);
Expand Down
Loading

0 comments on commit f7d8897

Please sign in to comment.