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

[오영택] sprint 5,9 #704

Open
wants to merge 62 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
3ec39b7
chore : 설치 및 설정
Programmeryeongtaek Jun 10, 2024
15cdb38
style : 기본 색상 및 설정
Programmeryeongtaek Jun 10, 2024
0e9a86e
feat : 기본 및 Home Page 설정
Programmeryeongtaek Jun 10, 2024
dc40da8
chore : axios 설치 및 설정
Programmeryeongtaek Jun 10, 2024
1a14863
chor : svgr, date-fns 설치 및 설정
Programmeryeongtaek Jun 11, 2024
518a2c8
feat : article 관련 인터페이스 작성
Programmeryeongtaek Jun 11, 2024
e2429ac
style : 세미콜론&설정 변경, 파비콘 추가
Programmeryeongtaek Jun 11, 2024
02c3e00
feat : 페이지 크기별 article fetch
Programmeryeongtaek Jun 11, 2024
beafdbc
test : LikeCount 컴포넌트
Programmeryeongtaek Jun 11, 2024
89953f3
feat : boardsPage 구성
Programmeryeongtaek Jun 11, 2024
6e604fa
feat : AllArticlesSection 페이지 및 검색시 경로 변경
Programmeryeongtaek Jun 11, 2024
c9b7e3d
style : BestSection CSS
Programmeryeongtaek Jun 11, 2024
7c235ce
fix : 일반 게시글 받아오기 위한 ㅓㄹ정
Programmeryeongtaek Jun 13, 2024
ebc3c6b
feat : 일반 게시글 받아오기 구현
Programmeryeongtaek Jun 13, 2024
8018ef6
feat : 검색 결과 게시글이 없을 때
Programmeryeongtaek Jun 13, 2024
8a2ed47
feat : Dropdown 기능 구현
Programmeryeongtaek Jun 13, 2024
12f448c
feat : Layout 컴포넌트 구현
Programmeryeongtaek Jun 13, 2024
c4feec8
feat : ItemsTypes 명시
Programmeryeongtaek Jun 13, 2024
8c22e77
feat : fetchItemsData
Programmeryeongtaek Jun 13, 2024
460e01a
feat : MarketPage 구현
Programmeryeongtaek Jun 13, 2024
29035f1
feat : ItemCard 구현
Programmeryeongtaek Jun 13, 2024
eb680bf
feat : BestItemsSection 구현
Programmeryeongtaek Jun 13, 2024
3c5d256
feat : AllItemsSection 구현
Programmeryeongtaek Jun 13, 2024
7888704
feat : 일반 상품 정렬 구현
Programmeryeongtaek Jun 13, 2024
79003dd
feat : 상품 검색 구현
Programmeryeongtaek Jun 13, 2024
ed8c0d8
feat : LoadingSpinner 구현
Programmeryeongtaek Jun 14, 2024
7304135
chore : next.config 설정 변경
Programmeryeongtaek Jun 14, 2024
8d9c477
style : LoadingSpinner 오타 정정
Programmeryeongtaek Jun 14, 2024
6e384fc
feat : Item 동적 페이지 데이터 받아오기
Programmeryeongtaek Jun 14, 2024
e6d72ad
feat : 동적 Item section 구현
Programmeryeongtaek Jun 14, 2024
775bc4b
feat : tag 기능 구현
Programmeryeongtaek Jun 14, 2024
0bccfbd
feat : ItemInfo 좋아요 버튼 생성
Programmeryeongtaek Jun 14, 2024
a6ecbc6
feat : items로 이동 구현
Programmeryeongtaek Jun 14, 2024
03cf374
feat : ItemCommentSection 구현
Programmeryeongtaek Jun 14, 2024
fe83896
feat : commentType 작성
Programmeryeongtaek Jun 14, 2024
1204eff
feat : formatUpdatedAt 함수 작성
Programmeryeongtaek Jun 14, 2024
242a5ea
style : 이름 변경
Programmeryeongtaek Jun 14, 2024
887f471
feat : 상품 댓글 기능 구현
Programmeryeongtaek Jun 14, 2024
8e788b1
feat : LoginPage 구상
Programmeryeongtaek Jun 14, 2024
9ba33d2
feat : HomePage 구상
Programmeryeongtaek Jun 14, 2024
eed23d2
feat : tailwind background-image 설정
Programmeryeongtaek Jun 14, 2024
6f61a9a
feat : AuthPage 구상
Programmeryeongtaek Jun 14, 2024
5e394fe
feat : input 컴포넌트 작성
Programmeryeongtaek Jun 15, 2024
21ccd78
refactor : social link 변경
Programmeryeongtaek Jun 15, 2024
c671874
feat : TagInput 컴포넌트 작성
Programmeryeongtaek Jun 15, 2024
34bed25
feat : addItemPage 구현 / button 수정 필요
Programmeryeongtaek Jun 15, 2024
a2e2184
feat : Button 공통 컴포넌트 작업 중
Programmeryeongtaek Jun 15, 2024
21325e0
feat : addArticle 컴포넌트 UI
Programmeryeongtaek Jun 15, 2024
5bcacd2
feat : DeleteButton 공통 컴포넌트
Programmeryeongtaek Jun 15, 2024
37e3f55
feat : handleSubmit 구현 && Post 기능 추가 필요
Programmeryeongtaek Jun 15, 2024
d79dfcc
feat : 상품 등록 미리보기 기능 구현
Programmeryeongtaek Jun 15, 2024
46280b8
refactor : 닫힌 태그 제거
Programmeryeongtaek Jun 15, 2024
6d90898
fix : LikeCount 컴포넌트 동적 css 구현
Programmeryeongtaek Jun 15, 2024
982fe4c
fix : Button 컴포넌트 테스트
Programmeryeongtaek Jun 15, 2024
3b4268a
style : 공통 컴포넌트 경로 변경
Programmeryeongtaek Jun 15, 2024
ca3a67b
refactor : LikeCount 공통 컴포넌트 개선
Programmeryeongtaek Jun 15, 2024
1611e0b
refactor : LikeCount 사용
Programmeryeongtaek Jun 15, 2024
7223aa9
style : ItemCard의 Like 크기 변경
Programmeryeongtaek Jun 15, 2024
bc4dec6
refactor : Input 컴포넌트 gap 속성
Programmeryeongtaek Jun 15, 2024
a1794e5
refactor : 공통 컴포넌트 경로 변경
Programmeryeongtaek Jun 15, 2024
fecbded
refactor : 컴포넌트 활용
Programmeryeongtaek Jun 15, 2024
2d71a6b
refactor : Input 컴포넌트 활용
Programmeryeongtaek Jun 15, 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
3 changes: 3 additions & 0 deletions next_mission/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals", "prettier"]
}
11 changes: 11 additions & 0 deletions next_mission/.prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"arrowParens": "always",
"endOfLine": "lf",
"printWidth": 80,
"quoteProps": "as-needed",
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"useTabs": false,
"trailingComma": "es5"
}
79 changes: 79 additions & 0 deletions next_mission/api/itemApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { ProductListFetcherParams } from '@/types/productTypes';

export async function getProducts({
orderBy,
pageSize,
page = 1,
}: ProductListFetcherParams) {
const params = new URLSearchParams({
orderBy,
pageSize: String(pageSize),
page: String(page),
})

try {
const res = await fetch(
Copy link
Collaborator

Choose a reason for hiding this comment

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

공통 api 함수가 하나 있으면 좋겠네요.

아래 baseApi 함수를 확인해주시면 됩니다.

type BaseApiMethods = "get" | "post" | "put" | "patch" | "delete";
type BaseApiCommonArgs = {
  url: URL;
  headers: RequestInit["headers"];
};

interface BaseApiNonBodyArgs extends BaseApiCommonArgs {
  method: "get" | "delete";
}

interface BaseApiWithBodyArgs extends BaseApiCommonArgs {
  method: "post" | "put" | "patch";
  body: RequestInit["body"];
}

type BaseApiArgs<T extends BaseApiMethods> = T extends "get"
  ? BaseApiNonBodyArgs
  : BaseApiWithBodyArgs;

type BaseApiArgsType = BaseApiNonBodyArgs | BaseApiWithBodyArgs;
type BaseApiFunc = <T extends BaseApiArgsType["method"]>(
  data: BaseApiArgs<T>
) => Promise<Response>;

const API_BASE_URL = "https://panda-market-api.vercel.app";

export const baseApi: BaseApiFunc = async ({ url, ...args }) => {
  try {
       const timeout = AbortSignal.timeout(5000); // timeout 5초
       return await fetch(`${API_BASE_URL}/${url}`, { ...args, signal: timeout });
  }
  catch(err) {
    // 실패했을 때 공통적으로 return 해줘야 할 응답값
  }
};

`https://panda-market-api.vercel.app/products?${params}`
);

if (!res.ok) {
throw new Error(`HTTP error: ${res.status}`)
}
const body = await res.json();
return body;
} catch (error) {
console.error('상품을 불러오는데 실패했습니다', error);
throw error;
}
}

export async function getProductDetail(productId: number) {
if (!productId) {
throw new Error("Invalid product ID");
}

try {
const res = await fetch(
`https://panda-market-api.vercel.app/products/${productId}`
);
if (!res.ok) {
throw new Error(`HTTP error: ${res.status}`);
}
const body = await res.json();
return body;
} catch (error) {
console.error('상품정보를 불러오는데 실패했습니다', error);
throw error;
};
}

export async function getProductComments({
productId,
limit = 10,
}: {
productId: number;
limit?: number;
}) {
if (!productId) {
throw new Error('Invalid product ID');
}

const params = {
limit: String(limit),
};

try {
const query = new URLSearchParams(params).toString();
const res = await fetch(
`https://panda-market-api.vercel.app/products/${productId}/comments?${query}`
);
if (!res.ok) {
throw new Error(`HTTP error: ${res.status}`);
}
const body = await res.json();
return body;
} catch (error) {
console.error('상품 댓글을 불러오는데 실패했습니다', error);
throw error;
}
}
81 changes: 81 additions & 0 deletions next_mission/components/boards/AllArticlesSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { Article, ArticleSortOption } from '@/types/articleTypes';
import Button from '../ui/Button';
import SearchBar from '../ui/SearchBar';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/router';
import Empty from '../ui/Empty';
import ArticleItem from './ArticleItem';
import Dropdown from '../ui/Dropdown';

interface AllArticlesSectionProps {
initialArticles: Article[];
}

const AllArticlesSection: React.FC<AllArticlesSectionProps> = ({
initialArticles,
}) => {
const [articles, setArticles] = useState(initialArticles);
const [orderBy, setOrderBy] = useState('recent');

const router = useRouter();
const keyword = (router.query.q as string) || '';

const handleSortSelection = (sortOption: ArticleSortOption) => {
setOrderBy(sortOption);
};

const handleInputKeyword = (inputKeyword: string) => {
const query = { ...router.query };
if (inputKeyword.trim()) {
query.q = inputKeyword;
} else {
delete query.q; // 객체이므로 삭제 가능
}
router.replace({
pathname: router.pathname,
query,
});
};

useEffect(() => {
const fetchArticles = async () => {
let url = `https://panda-market-api.vercel.app/articles?orderBy=${orderBy}`;
if (keyword.trim()) {
url += `&keyword=${encodeURIComponent(keyword)}`;
}
const res = await fetch(url);
const data = await res.json();
setArticles(data.list);
};

fetchArticles();
}, [orderBy, keyword]);

return (
<div className="flex flex-col gap-6 w-[1200px] m-auto">
<div className="flex justify-between">
<header>게시글</header>
<Button>글쓰기</Button>
</div>

<div className="flex justify-between">
<SearchBar onSearch={handleInputKeyword} />
<Dropdown
onSortSelection={handleSortSelection}
sortOptions={[
{ key: 'recent', label: '최신순' },
{ key: 'like', label: '인기순' },
]}
/>
</div>

{articles.length
? articles.map((article) => (
<ArticleItem key={`article-${article.id}`} article={article} />
))
: keyword && <Empty text={`'${keyword}'로 검색된 결과가 없습니다.`} />}
</div>
);
};

export default AllArticlesSection;
60 changes: 60 additions & 0 deletions next_mission/components/boards/ArticleItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Article } from '@/types/articleTypes';
import Image from 'next/image';
import Link from 'next/link';
import LikeCount from '../ui/LikeCount';
import { format } from 'date-fns';

// Todo : LikeCount gap 동적으로 입력하기

interface ArticleProps {
article: Article;
}

const ArticleItem: React.FC<ArticleProps> = ({ article }) => {
const dateString = format(article.createdAt, 'yyyy. MM. dd');
return (
<>
<Link
href={`/boards/${article.id}`}
className="flex flex-col gap-6 h-[136px] border-b border-[#e5e7eb]"
>
<div className="flex flex-col gap-4 justify-between">
<div className="flex justify-between gap-8 h-[72px]">
<h2 className="text-xl font-semibold text-[#1f2937]">
{article.title}
</h2>
{article.image && (
<div className="w-[72px] h-[72px] rounded-md border border-[#e5e7eb] relative">
<Image
fill
src={article.image}
alt={`${article.id}번 게시글 이미지`}
className="object-contain w-12 h-11 absolute"
/>
</div>
)}
</div>
<div className="flex gap-2 justify-between">
<div className="flex gap-2 items-center">
<span>아바타</span>
<div className="flex gap-2 h-[17px] text-[#4b5563] text-sm">
{article.writer.nickname}
<span className="text-[#9ca3af] text-sm">{dateString}</span>
</div>
</div>
<LikeCount
count={article.likeCount}
width={24}
height={24}
gap={8}
leading={24}
fontSize={16}
/>
</div>
</div>
</Link>
</>
);
};

export default ArticleItem;
49 changes: 49 additions & 0 deletions next_mission/components/boards/BestArticleCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import Link from 'next/link';
import MedalIcon from '@/public/images/icons/ic_medal.svg';
import Image from 'next/image';
import { Article } from '@/types/articleTypes';
import { format } from 'date-fns';
import LikeCount from '../ui/LikeCount';

const BestArticleCard = ({ article }: { article: Article }) => {
const dateString = format(article.createdAt, 'yyyy. MM. dd');
return (
<Link
href={`/boards/${article.id}`}
className="w-[384px] h-[169px] bg-[#F9FAFB] rounded-lg"
>
<div className="flex flex-col gap-3 w-[336px] h-[153px] m-auto">
<div className="flex gap-1 items-center w-[102px] h-[30px] bg-[#3692FF] justify-center text-white rounded-b-3xl text-base font-semibold">
<MedalIcon width={16} height={16} />
Best
</div>

<div className="flex w-[336px] h-[72px] gap-3">
<h2 className="w-[256px] font-semibold text-xl text-[#1f2937]">
{article.title}
</h2>
{article.image && (
<div className="w-[72px] h-[72px] rounded-md border border-[#e5e7eb] relative">
<Image
fill
src={article.image}
alt={`${article.id}번 게시글 이미지`}
className="object-contain w-12 h-11 absolute"
/>
</div>
)}
</div>

<div className="flex justify-between">
<div className="flex gap-2 items-center text-[#4b5563] font-normal h-4 text-sm">
{article.writer.nickname}
<LikeCount count={article.likeCount} fontSize={14} />
</div>
<span>{dateString}</span>
</div>
</div>
</Link>
);
};

export default BestArticleCard;
64 changes: 64 additions & 0 deletions next_mission/components/boards/BestArticlesSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { useEffect, useState } from 'react';
import BestArticleCard from './BestArticleCard';
import { Article, ArticleListData } from '@/types/articleTypes';
import useViewport from '@/hooks/useViewport';

const getPageSize = (width: number): number => {
if (width < 768) {
return 1; // Mobile
} else if (width < 1280) {
return 2; // Tablet
} else {
return 3; // Desktop
}
};

const BestArticlesSection = () => {
const [bestArticles, setBestArticles] = useState<Article[]>([]);
const [pageSize, setPageSize] = useState<number | null>(null);

const viewportWidth = useViewport();

useEffect(() => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

useEffect는 react의 여러 라이프사이클을 핸들링 할 수 있는 훅입니다.

그래서 내부에서 추가적으로 호출해줘야되는 함수들이 늘어날 수 있기 때문에, 함수의 구현부(fetchBestArticles)는 useEffect 외부로 빼주시면 좋습니다.

Copy link
Collaborator

Choose a reason for hiding this comment

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

전반적으로 기본적인 기능 구현사항이라 크게 커멘트를 남길 필요는 없을 듯 합니다.

그리고 현재 각 컴포넌트에서 api 호출 후, 응답 데이터를 state로 관리하여 ui 렌더링 시키는 구조가 많은 데,
setLoading(true) -> api response -> setLoading(false) -> setData 이런 반복적인 코드가 보이네요.

type UseApiArgs {
   // .....
}

// useApi 내부에서 실제 api 콜을 하는 함수
type Fetcher {
   // ....
}
const useApi = <T>(fetcher: Fetcher) => {
  const [data, setData] = useState<T>();
  const [isLoading, setIsLoading] = useState(false); 
  const [error, setError] = useState(); 
  
  const trigger = async () => {
     try{
        setIsLoading(true);
        const result = await fetcher();
        setIsLoading(false;
        setData(result)
     }
     catch(err) {
        setError(err);
     }
  } 

  return {
    data, isLoading, error, trigger
  }

} 

// ./page or ./component
const fetcher = () => {
    return fetch("", {});
}
const DummyComponent = () => {
    const {data, isLoading, error, trigger} = useApi(fetcher);

    return (
        <>
        {isLoading && <Loading />}
        {!isLoading && <div>{JSON.stringify(data)}</div>
        </>
     )
} 

if (viewportWidth === 0) return;

const newPageSize = getPageSize(viewportWidth);

if (newPageSize !== pageSize) {
setPageSize(newPageSize);

const fetchBestArticles = async (size: number) => {
try {
const res = await fetch(
`https://panda-market-api.vercel.app/articles?orderBy=like&pageSize=${size}`
);
const data: ArticleListData = await res.json();
setBestArticles(data.list);
} catch (error) {
console.error('베스트 게시물을 받아오는 데 실패했습니다.', error);
}
};

fetchBestArticles(newPageSize);
}
}, [viewportWidth, pageSize]);

return (
<div className="flex flex-col m-auto gap-6 w-[1200px]">
<header className="font-bold text-xl text-[#111827]">
베스트 게시글
</header>

<div className="flex justify-between">
{bestArticles.map((article) => (
<BestArticleCard
key={`best-article-${article.id}`}
article={article}
/>
))}
</div>
</div>
);
};

export default BestArticlesSection;
Loading