diff --git a/src/lib/api/remote/review.ts b/src/lib/api/remote/review.ts index 00b3c4c1..f62a9520 100644 --- a/src/lib/api/remote/review.ts +++ b/src/lib/api/remote/review.ts @@ -1,19 +1,19 @@ import { BASE_URL, DEFAULT_TIMEOUT } from '@src/lib/constants/client'; -import { PartCategoryType } from '@src/lib/types/blog'; +import { BlogResponse, PartCategoryType } from '@src/lib/types/blog'; import axios from 'axios'; import qs from 'qs'; -import { GetReviewsResponse, GetSampleReviewsResponse, ReviewAPI } from '../../types/review'; +import { GetSampleReviewsResponse, ReviewAPI } from '../../types/review'; const client = axios.create({ baseURL: BASE_URL, timeout: DEFAULT_TIMEOUT, }); -const getResponse = async ( +export const getResponse = async ( majorTab: number, subTab: PartCategoryType, pageNo = 1, -): Promise => { +): Promise => { const generationParameter = majorTab === 0 ? {} : { generation: majorTab }; const partParameter = subTab === PartCategoryType.ALL ? {} : { part: subTab }; const pageParameter = { pageNo, limit: 6 }; @@ -21,7 +21,7 @@ const getResponse = async ( const { data } = await client.get(`/reviews?${parameter}`); - return { hasNextPage: data.hasNextPage, response: data.data }; + return { hasNextPage: data.hasNextPage, response: data.data, currentPage: data.currentPage }; }; const getSampleReviews = async (): Promise => { diff --git a/src/lib/api/remote/sopticle.ts b/src/lib/api/remote/sopticle.ts index c96d9b61..1933417c 100644 --- a/src/lib/api/remote/sopticle.ts +++ b/src/lib/api/remote/sopticle.ts @@ -1,11 +1,6 @@ import { BASE_URL, DEFAULT_TIMEOUT } from '@src/lib/constants/client'; -import { BlogPostType } from '@src/lib/types/blog'; -import { PartCategoryType } from '@src/lib/types/blog'; -import { - GetSopticlesResponse, - PostSopticleLikeResponse, - SopticleAPI, -} from '@src/lib/types/sopticle'; +import { BlogPostType, BlogResponse, PartCategoryType } from '@src/lib/types/blog'; +import { PostSopticleLikeResponse, SopticleAPI } from '@src/lib/types/sopticle'; import { getStorageHandler } from '@src/lib/utils/storageHandler'; import axios from 'axios'; import { nanoid } from 'nanoid'; @@ -16,11 +11,11 @@ const client = axios.create({ timeout: DEFAULT_TIMEOUT, }); -const getResponse = async ( +export const getResponse = async ( majorTab: number, subTab: PartCategoryType, pageNo = 1, -): Promise => { +): Promise => { const generationParameter = majorTab === 0 ? {} : { generation: majorTab }; const partParameter = subTab === PartCategoryType.ALL ? {} : { part: subTab }; const pageParameter = { pageNo, limit: 6 }; @@ -28,14 +23,16 @@ const getResponse = async ( const sessionId = sessionStorageHandler.getItemOrGenerate('session-id', nanoid); const parameter = qs.stringify({ ...partParameter, ...pageParameter, ...generationParameter }); - const { data } = await client.get<{ hasNextPage: boolean; data: BlogPostType[] }>( - `/sopticle?${parameter}`, - { headers: { 'session-id': sessionId } }, - ); + const { data } = await client.get<{ + hasNextPage: boolean; + data: BlogPostType[]; + currentPage: number; + }>(`/sopticle?${parameter}`, { headers: { 'session-id': sessionId } }); return { hasNextPage: data.hasNextPage, response: data.data, + currentPage: data.currentPage, }; }; diff --git a/src/lib/types/blog.ts b/src/lib/types/blog.ts index 5f31b21a..8cc5f20c 100644 --- a/src/lib/types/blog.ts +++ b/src/lib/types/blog.ts @@ -37,3 +37,9 @@ export enum PartCategoryType { WEB = 'WEB', SERVER = 'SERVER', } + +export type BlogResponse = { + hasNextPage: boolean; + response: BlogPostType[]; + currentPage: number; +}; diff --git a/src/lib/types/review.ts b/src/lib/types/review.ts index e9170934..d5449428 100644 --- a/src/lib/types/review.ts +++ b/src/lib/types/review.ts @@ -1,20 +1,11 @@ import { PartCategoryType } from '@src/lib/types/blog'; -import { BlogPostType } from './blog'; +import { BlogPostType, BlogResponse } from './blog'; export type GetSampleReviewsResponse = { reviews: BlogPostType[]; }; -export type GetReviewsResponse = { - response: BlogPostType[]; - hasNextPage: boolean; -}; - export interface ReviewAPI { - getResponse( - majorTab: number, - subTab: PartCategoryType, - page: number, - ): Promise; + getResponse(majorTab: number, subTab: PartCategoryType, page: number): Promise; getSampleReviews(): Promise; } diff --git a/src/lib/types/sopticle.ts b/src/lib/types/sopticle.ts index da837cd5..2308eb4f 100644 --- a/src/lib/types/sopticle.ts +++ b/src/lib/types/sopticle.ts @@ -1,20 +1,11 @@ import { PartCategoryType } from '@src/lib/types/blog'; -import { BlogPostType } from './blog'; - -export type GetSopticlesResponse = { - hasNextPage: boolean; - response: BlogPostType[]; -}; +import { BlogResponse } from './blog'; export type PostSopticleLikeResponse = { currentLike: boolean; likeChanged: boolean; }; export interface SopticleAPI { - getResponse( - majorTab: number, - subTab: PartCategoryType, - page: number, - ): Promise; + getResponse(majorTab: number, subTab: PartCategoryType, page: number): Promise; postSopticleLike(sopticleId: number, prevLike: boolean): Promise; } diff --git a/src/views/BlogPage/BlogPage.tsx b/src/views/BlogPage/BlogPage.tsx index c7681012..8af3736e 100644 --- a/src/views/BlogPage/BlogPage.tsx +++ b/src/views/BlogPage/BlogPage.tsx @@ -1,12 +1,15 @@ +import { css } from '@emotion/react'; import PageLayout from '@src/components/common/PageLayout'; import useStorage from '@src/hooks/useStorage'; import { activeGenerationCategoryList } from '@src/lib/constants/tabs'; import { PartCategoryType } from '@src/lib/types/blog'; +import BlogPostSkeletonUI from '@src/views/BlogPage/components/BlogPostSkeletonUI'; import { OvalSpinner } from '@src/views/ProjectPage/components'; import BlogPostList from './components/BlogPostList'; import BlogTab from './components/BlogTab'; import { BlogTabType } from './components/BlogTab/types'; -import useFetch from './hooks/useFetch'; +import { useGetResponse } from './hooks/queries/useGetResponse'; +import useInfiniteScroll from './hooks/useInfiniteScroll'; import * as S from './style'; export default function BlogPage() { @@ -26,20 +29,26 @@ export default function BlogPage() { PartCategoryType.ALL, ); - const { - state: response, - ref, - canGetMoreResponse, - } = useFetch({ + const { response, hasNextPage, fetchNextPage, isFetching } = useGetResponse( selectedTab, selectedMajorCategory, selectedSubCategory, - }); + ); + const { ref } = useInfiniteScroll(fetchNextPage); - if (!(response._TAG === 'OK' || response._TAG === 'LOADING')) return null; + const showTotalPostList = () => { + setMajorCategory(activeGenerationCategoryList[0]); + setSubCategory(PartCategoryType.ALL); + }; return ( - + - - {(canGetMoreResponse || response._TAG === 'LOADING') && ( - - - + {!response ? ( + + ) : ( + <> + {response.length === 0 ? ( + + + {`아직 올라온 ${selectedTab === 'article' ? '아티클이' : '활동후기가'} 없어요`} + + {`${ + selectedTab === 'article' ? '아티클' : '활동후기' + } 전체 보기`} + + ) : ( + <> + + {(hasNextPage || isFetching) && ( + + + + )} + + )} + )} ); diff --git a/src/views/BlogPage/components/BlogPost/style.ts b/src/views/BlogPage/components/BlogPost/style.ts index 000edf6e..e3fb3b43 100644 --- a/src/views/BlogPage/components/BlogPost/style.ts +++ b/src/views/BlogPage/components/BlogPost/style.ts @@ -5,18 +5,20 @@ import Image from 'next/image'; export const BlogPost = styled.section` display: flex; justify-content: space-between; + max-width: 900px; width: 100%; gap: 36px; cursor: pointer; transition: opacity 0.2s linear; &:hover { - opacity: 0.8; + opacity: 0.7; } /* 모바일 뷰 */ @media (max-width: 767px) { gap: 16px; + width: 100%; } `; diff --git a/src/views/BlogPage/components/BlogPostList/index.tsx b/src/views/BlogPage/components/BlogPostList/index.tsx index 26faee1a..8bc802d3 100644 --- a/src/views/BlogPage/components/BlogPostList/index.tsx +++ b/src/views/BlogPage/components/BlogPostList/index.tsx @@ -1,45 +1,18 @@ -import { activeGenerationCategoryList } from '@src/lib/constants/tabs'; import type { BlogPostType } from '@src/lib/types/blog'; -import { PartCategoryType } from '@src/lib/types/blog'; import BlogPost from '@src/views/BlogPage/components/BlogPost'; import * as S from './style'; interface BlogPostListProps { selectedTap: string; // review || article - blogPostList: BlogPostType[]; - setMajorCategory: (newValue: number) => void; - setSubCategory: (newValue: PartCategoryType) => void; } -export default function BlogPostList({ - selectedTap, - blogPostList, - setMajorCategory, - setSubCategory, -}: BlogPostListProps) { - const showTotalPostList = () => { - setMajorCategory(activeGenerationCategoryList[0]); - setSubCategory(PartCategoryType.ALL); - }; +export default function BlogPostList({ selectedTap, blogPostList }: BlogPostListProps) { return ( - - {blogPostList.length === 0 ? ( - <> - - {`아직 올라온 ${selectedTap === 'article' ? '아티클이' : '활동후기가'} 없어요`} - - {`${ - selectedTap === 'article' ? '아티클' : '활동후기' - } 전체 보기`} - - ) : ( - - {blogPostList?.map((blogPost) => ( - - ))} - - )} - + + {blogPostList?.map((blogPost) => ( + + ))} + ); } diff --git a/src/views/BlogPage/components/BlogPostList/style.ts b/src/views/BlogPage/components/BlogPostList/style.ts index 1d8eca02..9d293d17 100644 --- a/src/views/BlogPage/components/BlogPostList/style.ts +++ b/src/views/BlogPage/components/BlogPostList/style.ts @@ -1,7 +1,6 @@ import styled from '@emotion/styled'; -import { colors } from '@sopt-makers/colors'; -export const BlogPostListWrapper = styled.div` +export const BlogPostList = styled.div` display: flex; flex-direction: column; align-items: center; @@ -10,68 +9,13 @@ export const BlogPostListWrapper = styled.div` padding-left: 20px; padding-right: 20px; margin-bottom: 108px; -`; -export const BlogPostList = styled.div` - display: flex; - flex-direction: column; - align-items: center; - gap: 80px; - max-width: 900px; - width: 100%; margin-top: 30px; + gap: 80px; /* 모바일 뷰 */ @media (max-width: 767px) { - width: 100%; margin-top: 24px; gap: 36px; } `; - -export const EmptyBlogPostList = styled.section` - color: ${colors.white}; - font-size: 32px; - font-weight: 700; - line-height: 48px; /* 150% */ - letter-spacing: -0.64px; - margin-top: 108px; - margin-bottom: 24px; - - /* 모바일 뷰 */ - @media (max-width: 767px) { - font-size: 18px; - line-height: 28px; /* 155.556% */ - letter-spacing: -0.36px; - margin-top: 90px; - margin-bottom: 16px; - } -`; - -export const Total = styled.button` - background-color: ${colors.gray10}; - - height: 42px; - padding: 0px 24px; - justify-content: center; - align-items: center; - gap: 10px; - border-radius: 8px; - color: ${colors.gray950}; - font-family: SUIT; - font-size: 16px; - font-weight: 600; - line-height: 22px; /* 137.5% */ - letter-spacing: -0.32px; - - cursor: pointer; - - /* 모바일 뷰 */ - @media (max-width: 767px) { - height: 36px; - padding: 8px 14px; - font-size: 14px; - line-height: 18px; /* 128.571% */ - letter-spacing: -0.28px; - } -`; diff --git a/src/views/BlogPage/components/BlogPostSkeletonUI/index.tsx b/src/views/BlogPage/components/BlogPostSkeletonUI/index.tsx new file mode 100644 index 00000000..cffe7a44 --- /dev/null +++ b/src/views/BlogPage/components/BlogPostSkeletonUI/index.tsx @@ -0,0 +1,30 @@ +import * as S from './style'; + +export default function BlogPostSkeletonUI() { + const BlogPostSkeletonUIList = [0, 1, 2]; + + return ( + + + {BlogPostSkeletonUIList.map((value) => ( + + + + + + + + + + + + + + + + + ))} + + + ); +} diff --git a/src/views/BlogPage/components/BlogPostSkeletonUI/style.ts b/src/views/BlogPage/components/BlogPostSkeletonUI/style.ts new file mode 100644 index 00000000..13ab7013 --- /dev/null +++ b/src/views/BlogPage/components/BlogPostSkeletonUI/style.ts @@ -0,0 +1,184 @@ +import styled from '@emotion/styled'; +import { colors } from '@sopt-makers/colors'; + +export const BlogPostListWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + + padding-left: 20px; + padding-right: 20px; + margin-bottom: 108px; +`; + +export const BlogPostList = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 80px; + max-width: 900px; + width: 100%; + + margin-top: 30px; + + /* 모바일 뷰 */ + @media (max-width: 767px) { + width: 100%; + margin-top: 24px; + gap: 36px; + } +`; + +export const BlogPost = styled.section` + display: flex; + justify-content: space-between; + width: 100%; + gap: 36px; + + transition: opacity 0.2s linear; + + /* 모바일 뷰 */ + @media (max-width: 767px) { + gap: 16px; + } +`; + +export const HeaderWrapper = styled.section` + display: flex; + flex-direction: column; + width: 100%; +`; + +export const Header = styled.div` + display: flex; + height: 23px; + width: 30px; + border-radius: 6px; + margin-bottom: 4px; + background-color: ${colors.gray800}; + + /* 모바일 뷰 */ + @media (max-width: 767px) { + height: 16px; + margin-bottom: 0; + } +`; + +export const Body = styled.div` + height: 94px; + width: 100%; + + /* 모바일 뷰 */ + @media (max-width: 767px) { + display: flex; + align-items: center; + height: 53px; + } +`; + +export const Profile = styled.div` + display: flex; + align-items: center; + gap: 6px; +`; + +export const ProfileImage = styled.div` + border-radius: 18px; + width: 18px; + height: 18px; + background-color: ${colors.gray800}; + + /* 모바일 뷰 */ + @media (max-width: 767px) { + width: 15px; + height: 15px; + } +`; + +export const Divider = styled.div` + padding: 0 2px 0 2px; +`; + +export const Title = styled.div` + height: 36px; + background-color: ${colors.gray800}; + border-radius: 6px; + margin-bottom: 5px; + width: 100%; + + @media (max-width: 767px) { + height: 24px; + } +`; + +export const Description = styled.div` + display: -webkit-box; + border-radius: 6px; + + background-color: ${colors.gray800}; + height: 52px; + + /* 모바일 뷰 */ + @media (max-width: 767px) { + display: none; + } +`; + +export const TagList = styled.div` + display: flex; + gap: 8px; + margin-top: 10px; + + /* 모바일 뷰 */ + @media (max-width: 767px) { + margin-top: 18px; + } +`; + +export const Tag = styled.div` + display: flex; + justify-content: center; + align-items: center; + + height: 28px; + width: 24px; + + border-radius: 6px; + background-color: ${colors.gray800}; + + /* 모바일 뷰 */ + @media (max-width: 767px) { + height: 20px; + } +`; + +export const ThumbnailWrapper = styled.div` + display: flex; + justify-content: center; + align-items: center; + + position: relative; + width: 239px; + + /* 모바일 뷰 */ + @media (max-width: 767px) { + width: 105px; + } +`; + +export const Thumbnail = styled.div` + border: 1px solid ${colors.gray900}; + border-radius: 8px; + object-fit: cover; + background-color: ${colors.gray800}; + width: 239px; + height: 160px; + + /* 모바일 뷰 */ + @media (max-width: 767px) { + width: 105px; + height: 70px; + border-radius: 5px; + } +`; diff --git a/src/views/BlogPage/components/BlogTab/style.ts b/src/views/BlogPage/components/BlogTab/style.ts index bc6a486a..a5681ba8 100644 --- a/src/views/BlogPage/components/BlogTab/style.ts +++ b/src/views/BlogPage/components/BlogTab/style.ts @@ -37,11 +37,7 @@ export const TabContainer = styled.section` align-items: center; `; -interface MenuTitleProps { - isSelected: boolean; -} - -export const TabTitle = styled.article` +export const TabTitle = styled.article<{ isSelected: boolean }>` font-size: 24px; height: 100%; line-height: 36px; diff --git a/src/views/BlogPage/hooks/queries/useGetResponse.ts b/src/views/BlogPage/hooks/queries/useGetResponse.ts new file mode 100644 index 00000000..1adb4f13 --- /dev/null +++ b/src/views/BlogPage/hooks/queries/useGetResponse.ts @@ -0,0 +1,39 @@ +import { useInfiniteQuery } from 'react-query'; +import { getResponse as getReviewResponse } from '@src/lib/api/remote/review'; +import { getResponse as getArticleResponse } from '@src/lib/api/remote/sopticle'; +import { PartCategoryType } from '@src/lib/types/blog'; +import { BlogTabType } from '../../components/BlogTab/types'; + +const getTabResponse = ( + selectedTab: BlogTabType, + generation: number, + part: PartCategoryType, + count: number, +) => { + return selectedTab === 'review' + ? getReviewResponse(generation, part, count) + : getArticleResponse(generation, part, count); +}; + +export const useGetResponse = ( + selectedTab: BlogTabType, + generation: number, + part: PartCategoryType, +) => { + const queryKey = [selectedTab, generation, part]; + + const { data, fetchNextPage, hasNextPage, isFetching } = useInfiniteQuery( + queryKey, + ({ pageParam = 1 }) => getTabResponse(selectedTab, generation, part, pageParam), + { + getNextPageParam: (lastPage) => (lastPage.hasNextPage ? lastPage.currentPage + 1 : undefined), + }, + ); + + return { + response: data?.pages.flatMap((page) => page.response), + hasNextPage, + fetchNextPage, + isFetching, + }; +}; diff --git a/src/views/BlogPage/hooks/useFetch.ts b/src/views/BlogPage/hooks/useFetch.ts deleted file mode 100644 index fb989735..00000000 --- a/src/views/BlogPage/hooks/useFetch.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { useCallback, useEffect } from 'react'; -import useBooleanState from '@src/hooks/useBooleanState'; -import useStackedFetchBase from '@src/hooks/useStackedFetchBase'; -import { api } from '@src/lib/api'; -import { PartCategoryType } from '@src/lib/types/blog'; -import { BlogTabType } from '../components/BlogTab/types'; -import useInfiniteScroll from './useInfiniteScroll'; - -interface useFetchProps { - selectedTab: BlogTabType; - selectedMajorCategory: number; - selectedSubCategory: PartCategoryType; -} - -const useFetch = ({ - selectedTab, - selectedMajorCategory: generation, - selectedSubCategory: part, -}: useFetchProps) => { - const tabAPI = selectedTab === 'review' ? 'reviewAPI' : 'sopticleAPI'; - const [canGetMoreResponse, setCanGetMoreResponse, setCanNotGetMoreResponse] = - useBooleanState(true); - const [isLoading, setIsLoading, setIsNotLoading] = useBooleanState(false); - const { ref, count, setCount } = useInfiniteScroll(isLoading); - - useEffect(() => { - function initializeStates() { - setCount(1); - setCanGetMoreResponse(); - } - return () => { - initializeStates(); - }; - }, [tabAPI, generation, part]); - - const willFetch = useCallback(async () => { - setIsLoading(); - setCanNotGetMoreResponse(); - - const response = await api[tabAPI]?.getResponse(generation, part, count); - // nextpage가 있을 때에만 추가로 response 받아오기 - response.hasNextPage ? setCanGetMoreResponse() : setCanNotGetMoreResponse(); - - setIsNotLoading(); - return response.response; - }, [tabAPI, part, generation, count, setCanGetMoreResponse, setCanNotGetMoreResponse]); - - const state = useStackedFetchBase(willFetch, count === 1); - - return { state, ref, canGetMoreResponse }; -}; - -export default useFetch; diff --git a/src/views/BlogPage/hooks/useInfiniteScroll.ts b/src/views/BlogPage/hooks/useInfiniteScroll.ts index cfc07264..84212136 100644 --- a/src/views/BlogPage/hooks/useInfiniteScroll.ts +++ b/src/views/BlogPage/hooks/useInfiniteScroll.ts @@ -1,29 +1,31 @@ -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; +import { FetchNextPageOptions, InfiniteQueryObserverResult } from 'react-query'; import useBooleanState from '@src/hooks/useBooleanState'; import useIntersectionObserver from '@src/hooks/useIntersectionObserver'; +import { BlogResponse } from '@src/lib/types/blog'; -export default function useInfiniteScroll(isLoading: boolean) { - const [count, setCount] = useState(1); +export default function useInfiniteScroll(fetchNextPage: { + (options?: FetchNextPageOptions): Promise>; + (): void; +}) { const [hasObserved, setHasObserved, setHasUnObserved] = useBooleanState(false); const ref = useIntersectionObserver( async (entry, observer) => { - if (!hasObserved && entry.isIntersecting && !isLoading) { - setCount((prevCount) => prevCount + 1); + if (!hasObserved && entry.isIntersecting) { + fetchNextPage(); setHasObserved(); } - // 엔트리를 관찰 중단합니다. observer.unobserve(entry.target); + setHasUnObserved(); }, { rootMargin: '80px' }, ); useEffect(() => { - if (!isLoading) { - setHasUnObserved(); - } - }, [isLoading]); + setHasUnObserved(); + }, []); - return { ref, count, setCount }; + return { ref }; } diff --git a/src/views/BlogPage/style.ts b/src/views/BlogPage/style.ts index cae22bc4..f3ff3c79 100644 --- a/src/views/BlogPage/style.ts +++ b/src/views/BlogPage/style.ts @@ -1,4 +1,5 @@ import styled from '@emotion/styled'; +import { colors } from '@sopt-makers/colors'; export const SpinnerWrapper = styled.section` display: flex; @@ -6,3 +7,59 @@ export const SpinnerWrapper = styled.section` align-items: center; margin: 50px 0; `; + +export const EmptyBlogPostListWrapper = styled.section` + display: flex; + flex-direction: column; + align-items: center; +`; + +export const EmptyBlogPostList = styled.section` + color: ${colors.white}; + font-size: 32px; + font-weight: 700; + line-height: 48px; /* 150% */ + letter-spacing: -0.64px; + margin-top: 108px; + margin-bottom: 24px; + text-align: center; + + /* 모바일 뷰 */ + @media (max-width: 767px) { + font-size: 18px; + line-height: 28px; /* 155.556% */ + letter-spacing: -0.36px; + margin-top: 90px; + margin-bottom: 16px; + } +`; + +export const Total = styled.button` + background-color: ${colors.gray10}; + + height: 42px; + padding: 0px 24px; + justify-content: center; + align-items: center; + gap: 10px; + border-radius: 8px; + color: ${colors.gray950}; + font-family: SUIT; + font-size: 16px; + font-weight: 600; + line-height: 22px; /* 137.5% */ + letter-spacing: -0.32px; + width: fit-content; + margin-bottom: 108px; + + cursor: pointer; + + /* 모바일 뷰 */ + @media (max-width: 767px) { + height: 36px; + padding: 8px 14px; + font-size: 14px; + line-height: 18px; /* 128.571% */ + letter-spacing: -0.28px; + } +`;