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 #127

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": ["babel-plugin-styled-components"]
}
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
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}`;
Copy link
Collaborator

Choose a reason for hiding this comment

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

P3:
공통으로 사용되는 기본 주소는 변수에 저장해두고 쓰셔도 좋을 것 같아요~

Suggested change
let url = `https://panda-market-api.vercel.app/articles?orderBy=${orderBy}`;
let url = `${BASE_URL}/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="/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이 보이지 않도록 조건 추가
Comment on lines +151 to +152
Copy link
Collaborator

Choose a reason for hiding this comment

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

P3:
좋은 주석이 많네요~ empty UI, skeleton UI 등을 구현하는것이 늘 더 좋습니다~

keyword && (
<EmptyState text={`'${keyword}'로 검색된 결과가 없어요.`} />
)}
</div>
);
};

export default AllArticlesSection;
166 changes: 166 additions & 0 deletions components/boards/BestArticlesSection.tsx
Copy link
Collaborator

Choose a reason for hiding this comment

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

P3:
많은 주석과 여러개의 컴포넌트, styledComponent 로 인해 파일의 가독성이 떨어집니다.
분리하시면 더 좋을 것 같아요~

Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { useEffect, useState } from "react";
import styled from "styled-components";
import Image from "next/image";
import Link from "next/link";
import { format } from "date-fns";
import {
FlexRowCentered,
SectionHeader,
SectionTitle,
} from "@/styles/CommonStyles";
import { Article, ArticleListResponse } from "@/types/articleTypes";
import {
ArticleInfo,
ArticleInfoDiv,
ArticleThumbnail,
ArticleTitle,
ImageWrapper,
MainContent,
Timestamp,
} from "@/styles/BoardsStyles";
import MedalIcon from "@/public/images/icons/ic_medal.svg";
import useViewport from "@/hooks/useViewport";
import LikeCountDisplay from "@/components/ui/LikeCountDisplay";

const CardContainer = styled(Link)`
background-color: var(--gray-50);
border-radius: 8px;
`;

const ContentWrapper = styled.div`
padding: 16px 24px;
`;

const BestSticker = styled(FlexRowCentered)`
background-color: var(--blue);
border-radius: 0 0 32px 32px;
font-size: 16px;
font-weight: 600;
color: #fff;
gap: 4px;
padding: 6px 24px 8px 24px;
margin-left: 24px;
display: inline-flex;
`;

const BestArticleCard = ({ article }: { article: Article }) => {
const dateString = format(article.createdAt, "yyyy. MM. dd");

return (
<CardContainer href={`/boards/${article.id}`}>
<BestSticker>
<MedalIcon alt="베스트 게시글" />
Best
</BestSticker>

<ContentWrapper>
<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>
{article.writer.nickname}
<LikeCountDisplay count={article.likeCount} fontSize={14} />
</ArticleInfoDiv>
<Timestamp>{dateString}</Timestamp>
</ArticleInfo>
</ContentWrapper>
</CardContainer>
);
};

const BestArticlesCardSection = styled.div`
display: grid;
grid-template-columns: repeat(1, 1fr);

@media ${({ theme }) => theme.mediaQuery.tablet} {
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}

@media ${({ theme }) => theme.mediaQuery.desktop} {
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
`;

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

const BestArticlesSection = () => {
const [articles, setArticles] = useState<Article[]>([]);
const [pageSize, setPageSize] = useState<number | null>(null); // 초기 값을 실제 사용되는 값인 1 또는 3으로 설정해도 되지만, 이 경우에는 화면 크기가 파악되기 전에는 pageSize가 설정되지 않았음을 명확히 하기 위해 null로 두었어요.

// Server-side rendering을 기본으로 하는 Next.js에서는 일반 리액트에서처럼 바로 window 객체를 사용하지 못하기 때문에, 별도의 useViewport 커스텀 훅을 만들었어요.
const viewportWidth = useViewport();

// 베스트 게시글 섹션의 요구사항에 따르면, 화면 크기에 따라 몇 개의 데이터를 보여줄지 여부(pageSize)를 결정하고 해당 값을 query parameter로 넣어 데이터를 호출해야 해요.
// 화면 크기가 파악된 후에 처리해야 하는 방식이기 때문에 client-side에서 호출해야 하고, 따라서 server-side에서 미리 내용을 받아오는 Next.js의 prefetching 기능을 사용할 수 없어요.
// (참고: 요구사항을 유연하게 해석한다면, pageSize을 필요한 데이터 길이의 최대값인 3으로 두어 prefetching한 후에 client-side에서 화면 크기에 따라 데이터 배열을 절삭해 사용하는 방법도 있어요.)
useEffect(() => {
// 화면 크기가 파악되기 전까지 pageSize 계산이나 데이터를 호출하지 않도록 처리
if (viewportWidth === 0) return;

// 화면 크기가 바뀔 때마다 불필요하게 데이터를 호출하는 것을 막기 위해, 화면 크기에 따른 pageSize 범위가 바뀔 때만 호출하도록 처리
const newPageSize = getPageSize(viewportWidth);

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

const fetchBestArticles = async (size: number) => {
try {
const response = await fetch(
`https://panda-market-api.vercel.app/articles?orderBy=like&pageSize=${size}`
);
const data: ArticleListResponse = await response.json();
setArticles(data.list);
} catch (error) {
console.error("Failed to fetch best articles:", error);
}
};

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

return (
<div>
<SectionHeader>
<SectionTitle>베스트 게시글</SectionTitle>
</SectionHeader>

<BestArticlesCardSection>
{articles.map((article) => (
<BestArticleCard
key={`best-article-${article.id}`}
article={article}
/>
))}
</BestArticlesCardSection>
</div>
);
};

export default BestArticlesSection;
Loading
Loading