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

[임귀태] Sprint10 #301

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 .babelrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"presets": ["next/babel"],
"plugins": [["styled-components", { "ssr": true }]]
}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz

# testing
/coverage
Expand Down
79 changes: 79 additions & 0 deletions 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 response = await fetch(
`https://panda-market-api.vercel.app/products?${params}`
);

if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
const body = await response.json();
return body;
} catch (error) {
console.error("Failed to fetch products:", error);
throw error;
}
}

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

try {
const response = await fetch(
`https://panda-market-api.vercel.app/products/${productId}`
);
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
const body = await response.json();
return body;
} catch (error) {
console.error("Failed to fetch product detail:", 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 response = await fetch(
`https://panda-market-api.vercel.app/products/${productId}/comments?${query}`
);
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
const body = await response.json();
return body;
} catch (error) {
console.error("Failed to fetch product comments:", error);
throw error;
}
}
160 changes: 160 additions & 0 deletions components/boards/AllArticlesSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import {
FlexRowCentered,
LineDivider,
SectionHeader,
SectionTitle,
StyledLink,
} from "@/styles/CommonStyles";
import { Article, ArticleSortOption } from "@/types/articleTypes";
import styled from "styled-components";
import {
ArticleInfo,
ArticleThumbnail,
ArticleTitle,
ImageWrapper,
MainContent,
Timestamp,
} from "@/styles/BoardsStyles";
import Image from "next/image";
import { format } from "date-fns";
import Link from "next/link";
import ProfilePlaceholder from "@/public/images/ui/ic_profile.svg";
import SearchBar from "@/components/ui/SearchBar";
import DropdownMenu from "@/components/ui/DropdownMenu";
import { useEffect, useState } from "react";
import LikeCountDisplay from "@/components/ui/LikeCountDisplay";
import EmptyState from "@/components/ui/EmptyState";
import { useRouter } from "next/router";

const ItemContainer = styled(Link)``;

const ArticleInfoDiv = styled(FlexRowCentered)`
gap: 8px;
color: var(--gray-600);
font-size: 14px;
`;

interface ArticleItemProps {
article: Article;
}

const ArticleItem: React.FC<ArticleItemProps> = ({ article }) => {
const dateString = format(article.createdAt, "yyyy. MM. dd");

return (
<>
<ItemContainer href={`/boards/${article.id}`}>
<MainContent>
<ArticleTitle>{article.title}</ArticleTitle>
{article.image && (
<ArticleThumbnail>
{/* Next Image의 width, height을 설정해줄 것이 아니라면 부모 div 내에서 fill, objectFit 설정으로 비율 유지하면서 유연하게 크기 조정 */}
{/* 프로젝트 내에 있는 이미지 파일을 사용하는 게 아니라면 next.config.mjs에 이미지 주소 설정 필요 */}
<ImageWrapper>
<Image
fill
src={article.image}
alt={`${article.id}번 게시글 이미지`}
style={{ objectFit: "contain" }}
/>
Comment on lines +53 to +59
Copy link
Collaborator

Choose a reason for hiding this comment

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

Next.js Image 컴포넌트 사용 시 sizes 속성을 추가하여 더 나은 이미지 최적화를 할 수 있습니다.
아래 문서를 참조해주세요~
https://nextjs.org/docs/pages/api-reference/components/image

</ImageWrapper>
</ArticleThumbnail>
)}
</MainContent>

<ArticleInfo>
<ArticleInfoDiv>
{/* ProfilePlaceholder 아이콘의 SVG 파일에서 고정된 width, height을 삭제했어요 */}
{/* <ProfilePlaceholder width={24} height={24} /> */}
{article.writer.nickname} <Timestamp>{dateString}</Timestamp>
</ArticleInfoDiv>

<LikeCountDisplay count={article.likeCount} iconWidth={24} gap={8} />
</ArticleInfo>
</ItemContainer>

<LineDivider $margin="24px 0" />
</>
);
};

const AddArticleLink = styled(StyledLink)``;

interface AllArticlesSectionProps {
initialArticles: Article[];
}

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

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

const handleSortSelection = (sortOption: ArticleSortOption) => {
setOrderBy(sortOption);
};
Comment on lines +96 to +98
Copy link
Collaborator

Choose a reason for hiding this comment

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

전반적으로 코드 잘 작성하셨는데요, 불필요한 리랜더링 방지하기 위해 아래처럼 useCallback 사용도 고민 해보면 더 좋겠어요.

const handleSortSelection = useCallback((sortOption: ArticleSortOption) => {
    setOrderBy(sortOption);
  }, []);


const handleSearch = (searchKeyword: string) => {
const query = { ...router.query };
if (searchKeyword.trim()) {
query.q = searchKeyword;
} else {
delete query.q; // Optional: 키워드가 빈 문자열일 때 URL에서 query string 없애주기
}
router.replace({
pathname: router.pathname,
query,
});
};

useEffect(() => {
const fetchArticles = async () => {
let url = `https://panda-market-api.vercel.app/articles?orderBy=${orderBy}`;
if (keyword.trim()) {
// encodeURIComponent는 공백이나 특수 문자 등 URL에 포함될 수 없는 문자열을 안전하게 전달할 수 있도록 인코딩하는 자바스크립트 함수예요.
url += `&keyword=${encodeURIComponent(keyword)}`;
}
const response = await fetch(url);
const data = await response.json();
setArticles(data.list);
};

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

Comment on lines +113 to +127
Copy link
Collaborator

Choose a reason for hiding this comment

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

요거 같은 경우는 useArticles 커스텀 훅을 별도로 빼서 사용하는 방법이 재사용성이 올라가고 공통으로 에러 처리등 일관된 처리를 진행할 수 있어서 아래처럼 빼 보면 좋겠습니다.

  const { articles, loading, error } = useArticles(orderBy, keyword);

return (
<div>
<SectionHeader>
<SectionTitle>게시글</SectionTitle>
{/* 참고: 임의로 /addArticle 이라는 pathname으로 게시글 작성 페이지를 추가했어요 */}
<AddArticleLink href="/addArticle">글쓰기</AddArticleLink>
</SectionHeader>

<SectionHeader>
<SearchBar onSearch={handleSearch} />
<DropdownMenu
onSortSelection={handleSortSelection}
sortOptions={[
{ key: "recent", label: "최신순" },
{ key: "like", label: "인기순" },
]}
/>
</SectionHeader>

{articles.length
? articles.map((article) => (
<ArticleItem key={`article-${article.id}`} article={article} />
))
: // 참고: 요구사항에는 없었지만 항상 Empty State UI 구현하는 걸 잊지 마세요! Empty State을 재사용 가능한 컴포넌트로 만들었어요.
// 키워드가 입력되지 않은 상태에서 검색 시 Empty State이 보이지 않도록 조건 추가
keyword && (
<EmptyState text={`'${keyword}'로 검색된 결과가 없어요.`} />
)}
</div>
);
};

export default AllArticlesSection;
Loading
Loading