-
Notifications
You must be signed in to change notification settings - Fork 21
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
The head ref may contain hidden characters: "Next-\uC774\uC218\uC9C0-sprint10"
[이수지] sprint10 #127
Changes from all commits
571057d
b38462b
89f85c8
0695da8
0ffe142
47e9f14
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
{ | ||
"presets": ["next/babel"], | ||
"plugins": ["babel-plugin-styled-components"] | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,6 +4,7 @@ | |
/node_modules | ||
/.pnp | ||
.pnp.js | ||
.yarn/install-state.gz | ||
|
||
# testing | ||
/coverage | ||
|
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="/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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: |
||
keyword && ( | ||
<EmptyState text={`'${keyword}'로 검색된 결과가 없어요.`} /> | ||
)} | ||
</div> | ||
); | ||
}; | ||
|
||
export default AllArticlesSection; |
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: |
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; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P3:
공통으로 사용되는 기본 주소는 변수에 저장해두고 쓰셔도 좋을 것 같아요~