Skip to content

Commit 6d2de49

Browse files
Merge pull request #294 from qpalkim/Next-김휘송-sprint9
[김휘송] Sprint9
2 parents 8d163dd + 1e75ed6 commit 6d2de49

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

76 files changed

+2682
-458
lines changed

api/itemApi.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { ProductListFetcherParams } from "@/types/productTypes";
2+
3+
export async function getProducts({
4+
orderBy,
5+
pageSize,
6+
page = 1,
7+
}: ProductListFetcherParams) {
8+
const params = new URLSearchParams({
9+
orderBy,
10+
pageSize: String(pageSize),
11+
page: String(page),
12+
});
13+
14+
try {
15+
const response = await fetch(
16+
`https://panda-market-api.vercel.app/products?${params}`
17+
);
18+
19+
if (!response.ok) {
20+
throw new Error(`HTTP error: ${response.status}`);
21+
}
22+
const body = await response.json();
23+
return body;
24+
} catch (error) {
25+
console.error("Failed to fetch products:", error);
26+
throw error;
27+
}
28+
}
29+
30+
export async function getProductDetail(productId: number) {
31+
if (!productId) {
32+
throw new Error("Invalid product ID");
33+
}
34+
35+
try {
36+
const response = await fetch(
37+
`https://panda-market-api.vercel.app/products/${productId}`
38+
);
39+
if (!response.ok) {
40+
throw new Error(`HTTP error: ${response.status}`);
41+
}
42+
const body = await response.json();
43+
return body;
44+
} catch (error) {
45+
console.error("Failed to fetch product detail:", error);
46+
throw error;
47+
}
48+
}
49+
50+
export async function getProductComments({
51+
productId,
52+
limit = 10,
53+
}: {
54+
productId: number;
55+
limit?: number;
56+
}) {
57+
if (!productId) {
58+
throw new Error("Invalid product ID");
59+
}
60+
61+
const params = {
62+
limit: String(limit),
63+
};
64+
65+
try {
66+
const query = new URLSearchParams(params).toString();
67+
const response = await fetch(
68+
`https://panda-market-api.vercel.app/products/${productId}/comments?${query}`
69+
);
70+
if (!response.ok) {
71+
throw new Error(`HTTP error: ${response.status}`);
72+
}
73+
const body = await response.json();
74+
return body;
75+
} catch (error) {
76+
console.error("Failed to fetch product comments:", error);
77+
throw error;
78+
}
79+
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import {
2+
FlexRowCentered,
3+
LineDivider,
4+
SectionHeader,
5+
SectionTitle,
6+
StyledLink,
7+
} from "@/styles/CommonStyles";
8+
import { Article, ArticleSortOption } from "@/types/articleTypes";
9+
import styled from "styled-components";
10+
import {
11+
ArticleInfo,
12+
ArticleThumbnail,
13+
ArticleTitle,
14+
ImageWrapper,
15+
MainContent,
16+
Timestamp,
17+
} from "@/styles/BoardsStyles";
18+
import Image from "next/image";
19+
import { format } from "date-fns";
20+
import Link from "next/link";
21+
import ProfilePlaceholder from "@/public/images/ui/ic_profile.svg";
22+
import SearchBar from "@/components/ui/SearchBar";
23+
import DropdownMenu from "@/components/ui/DropdownMenu";
24+
import { useEffect, useState } from "react";
25+
import LikeCountDisplay from "@/components/ui/LikeCountDisplay";
26+
import EmptyState from "@/components/ui/EmptyState";
27+
import { useRouter } from "next/router";
28+
29+
const ItemContainer = styled(Link)``;
30+
31+
const ArticleInfoDiv = styled(FlexRowCentered)`
32+
gap: 8px;
33+
color: var(--gray-600);
34+
font-size: 14px;
35+
`;
36+
37+
interface ArticleItemProps {
38+
article: Article;
39+
}
40+
41+
const ArticleItem: React.FC<ArticleItemProps> = ({ article }) => {
42+
const dateString = format(article.createdAt, "yyyy. MM. dd");
43+
44+
return (
45+
<>
46+
<ItemContainer href={`/boards/${article.id}`}>
47+
<MainContent>
48+
<ArticleTitle>{article.title}</ArticleTitle>
49+
{article.image && (
50+
<ArticleThumbnail>
51+
{/* Next Image의 width, height을 설정해줄 것이 아니라면 부모 div 내에서 fill, objectFit 설정으로 비율 유지하면서 유연하게 크기 조정 */}
52+
{/* 프로젝트 내에 있는 이미지 파일을 사용하는 게 아니라면 next.config.mjs에 이미지 주소 설정 필요 */}
53+
<ImageWrapper>
54+
<Image
55+
fill
56+
src={article.image}
57+
alt={`${article.id}번 게시글 이미지`}
58+
style={{ objectFit: "contain" }}
59+
/>
60+
</ImageWrapper>
61+
</ArticleThumbnail>
62+
)}
63+
</MainContent>
64+
65+
<ArticleInfo>
66+
<ArticleInfoDiv>
67+
{/* ProfilePlaceholder 아이콘의 SVG 파일에서 고정된 width, height을 삭제했어요 */}
68+
{/* <ProfilePlaceholder width={24} height={24} /> */}
69+
{article.writer.nickname} <Timestamp>{dateString}</Timestamp>
70+
</ArticleInfoDiv>
71+
72+
<LikeCountDisplay count={article.likeCount} iconWidth={24} gap={8} />
73+
</ArticleInfo>
74+
</ItemContainer>
75+
76+
<LineDivider $margin="24px 0" />
77+
</>
78+
);
79+
};
80+
81+
const AddArticleLink = styled(StyledLink)``;
82+
83+
interface AllArticlesSectionProps {
84+
initialArticles: Article[];
85+
}
86+
87+
const AllArticlesSection: React.FC<AllArticlesSectionProps> = ({
88+
initialArticles,
89+
}) => {
90+
const [orderBy, setOrderBy] = useState<ArticleSortOption>("recent");
91+
const [articles, setArticles] = useState(initialArticles);
92+
93+
const router = useRouter();
94+
const keyword = (router.query.q as string) || "";
95+
96+
const handleSortSelection = (sortOption: ArticleSortOption) => {
97+
setOrderBy(sortOption);
98+
};
99+
100+
const handleSearch = (searchKeyword: string) => {
101+
const query = { ...router.query };
102+
if (searchKeyword.trim()) {
103+
query.q = searchKeyword;
104+
} else {
105+
delete query.q; // Optional: 키워드가 빈 문자열일 때 URL에서 query string 없애주기
106+
}
107+
router.replace({
108+
pathname: router.pathname,
109+
query,
110+
});
111+
};
112+
113+
useEffect(() => {
114+
const fetchArticles = async () => {
115+
let url = `https://panda-market-api.vercel.app/articles?orderBy=${orderBy}`;
116+
if (keyword.trim()) {
117+
// encodeURIComponent는 공백이나 특수 문자 등 URL에 포함될 수 없는 문자열을 안전하게 전달할 수 있도록 인코딩하는 자바스크립트 함수예요.
118+
url += `&keyword=${encodeURIComponent(keyword)}`;
119+
}
120+
const response = await fetch(url);
121+
const data = await response.json();
122+
setArticles(data.list);
123+
};
124+
125+
fetchArticles();
126+
}, [orderBy, keyword]);
127+
128+
return (
129+
<div>
130+
<SectionHeader>
131+
<SectionTitle>게시글</SectionTitle>
132+
{/* 참고: 임의로 /addArticle 이라는 pathname으로 게시글 작성 페이지를 추가했어요 */}
133+
<AddArticleLink href="/addArticle">글쓰기</AddArticleLink>
134+
</SectionHeader>
135+
136+
<SectionHeader>
137+
<SearchBar onSearch={handleSearch} />
138+
<DropdownMenu
139+
onSortSelection={handleSortSelection}
140+
sortOptions={[
141+
{ key: "recent", label: "최신순" },
142+
{ key: "like", label: "인기순" },
143+
]}
144+
/>
145+
</SectionHeader>
146+
147+
{articles.length
148+
? articles.map((article) => (
149+
<ArticleItem key={`article-${article.id}`} article={article} />
150+
))
151+
: // 참고: 요구사항에는 없었지만 항상 Empty State UI 구현하는 걸 잊지 마세요! Empty State을 재사용 가능한 컴포넌트로 만들었어요.
152+
// 키워드가 입력되지 않은 상태에서 검색 시 Empty State이 보이지 않도록 조건 추가
153+
keyword && (
154+
<EmptyState text={`'${keyword}'로 검색된 결과가 없어요.`} />
155+
)}
156+
</div>
157+
);
158+
};
159+
160+
export default AllArticlesSection;

0 commit comments

Comments
 (0)