Skip to content

Commit

Permalink
Merge pull request #308 from KimDasom521/next-김다솜
Browse files Browse the repository at this point in the history
[김다솜] Sprint10
  • Loading branch information
wlgns2223 authored Aug 26, 2024
2 parents 8d163dd + 1525a97 commit 2d66572
Show file tree
Hide file tree
Showing 168 changed files with 5,515 additions and 119 deletions.
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 }]]
}
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
/node_modules
/.pnp
.pnp.js

.yarn/install-state.gz
# testing
/coverage

Expand Down Expand Up @@ -33,3 +33,4 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts

50 changes: 50 additions & 0 deletions api/articleApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
export async function getArticleDetail(articleId: number) {
if (!articleId) {
throw new Error("Invalid article ID");
}

try {
const response = await fetch(
`https://panda-market-api.vercel.app/articles/${articleId}`
);
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
const body = await response.json();
return body;
} catch (error) {
console.error("Failed to fetch article detail:", error);
throw error;
}
}

export async function getArticleComments({
articleId,
limit = 10,
}: {
articleId: number;
limit?: number;
}) {
if (!articleId) {
throw new Error("Invalid article ID");
}

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

try {
const query = new URLSearchParams(params).toString();
const response = await fetch(
`https://panda-market-api.vercel.app/articles/${articleId}/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 article comments:", error);
throw error;
}
}
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" }}
/>
</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);
};

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]);

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

0 comments on commit 2d66572

Please sign in to comment.