From 571057dbf4ab33af514baaf6edbf86f8a86c704b Mon Sep 17 00:00:00 2001 From: Suzy-Lee <> Date: Wed, 30 Oct 2024 17:21:48 +0900 Subject: [PATCH 1/6] Init Mission10 project --- .babelrc | 4 + .gitignore | 1 + components/boards/AllArticlesSection.tsx | 160 + components/boards/BestArticlesSection.tsx | 166 + components/items/itemPage/CommentThread.tsx | 147 + .../items/itemPage/ItemCommentSection.tsx | 94 + .../items/itemPage/ItemProfileSection.tsx | 160 + components/items/itemPage/LikeButton.tsx | 55 + components/items/itemPage/TagDisplay.tsx | 33 + .../items/marketPage/AllItemsSection.tsx | 148 + .../items/marketPage/BestItemsSection.tsx | 101 + components/items/marketPage/ItemCard.tsx | 62 + components/layout/Header.tsx | 94 + components/layout/Layout.tsx | 22 + components/ui/DropdownMenu.tsx | 79 + components/ui/EmptyState.tsx | 35 + components/ui/Icon.tsx | 51 + components/ui/LikeCountDisplay.tsx | 46 + components/ui/LoadingSpinner.tsx | 67 + components/ui/PaginationBar.tsx | 84 + components/ui/SearchBar.tsx | 71 + declarations.d.ts | 15 + hooks/useViewport.ts | 16 + next.config.js | 6 - next.config.mjs | 24 + package-lock.json | 13229 +++++++++++++--- package.json | 14 +- pages/_app.tsx | 18 +- pages/_document.tsx | 59 +- pages/addArticle/index.tsx | 7 + pages/additem/index.tsx | 7 + pages/api/hello.ts | 13 - pages/boards/[id].tsx | 12 + pages/boards/index.tsx | 41 + pages/index.tsx | 106 +- pages/items/[id].tsx | 90 + pages/items/index.tsx | 14 + public/favicon.ico | Bin 25931 -> 15406 bytes public/images/home/bottom-banner-image.png | Bin 0 -> 72091 bytes public/images/home/feature1-image.png | Bin 0 -> 25334 bytes public/images/home/feature2-image.png | Bin 0 -> 30978 bytes public/images/home/feature3-image.png | Bin 0 -> 21270 bytes public/images/home/hero-image.png | Bin 0 -> 71399 bytes public/images/icons/arrow_left.svg | 3 + public/images/icons/arrow_right.svg | 3 + public/images/icons/eye-invisible.svg | 10 + public/images/icons/eye-visible.svg | 3 + public/images/icons/ic_back.svg | 4 + public/images/icons/ic_heart.svg | 3 + public/images/icons/ic_kebab.svg | 5 + public/images/icons/ic_medal.svg | 4 + public/images/icons/ic_plus.svg | 4 + public/images/icons/ic_search.svg | 3 + public/images/icons/ic_sort.svg | 6 + public/images/icons/ic_x.svg | 4 + public/images/logo/logo.svg | 15 + public/images/social/facebook-logo.svg | 3 + public/images/social/google-logo.png | Bin 0 -> 2266 bytes public/images/social/instagram-logo.svg | 3 + public/images/social/kakao-logo.png | Bin 0 -> 1580 bytes public/images/social/twitter-logo.svg | 3 + public/images/social/youtube-logo.svg | 10 + public/images/ui/empty-comments.svg | 17 + public/images/ui/ic_profile.svg | 24 + public/next.svg | 1 - public/vercel.svg | 1 - styles/BoardsStyles.ts | 52 + styles/CommonStyles.ts | 101 + styles/GlobalStyle.ts | 137 + styles/Home.module.css | 229 - styles/MarketStyles.ts | 8 + styles/globals.css | 107 - styles/theme.ts | 31 + tsconfig.json | 1 - types/articleTypes.ts | 17 + types/commentTypes.ts | 16 + types/productTypes.ts | 29 + utils/dateUtils.ts | 44 + 78 files changed, 13576 insertions(+), 2676 deletions(-) create mode 100644 .babelrc create mode 100644 components/boards/AllArticlesSection.tsx create mode 100644 components/boards/BestArticlesSection.tsx create mode 100644 components/items/itemPage/CommentThread.tsx create mode 100644 components/items/itemPage/ItemCommentSection.tsx create mode 100644 components/items/itemPage/ItemProfileSection.tsx create mode 100644 components/items/itemPage/LikeButton.tsx create mode 100644 components/items/itemPage/TagDisplay.tsx create mode 100644 components/items/marketPage/AllItemsSection.tsx create mode 100644 components/items/marketPage/BestItemsSection.tsx create mode 100644 components/items/marketPage/ItemCard.tsx create mode 100644 components/layout/Header.tsx create mode 100644 components/layout/Layout.tsx create mode 100644 components/ui/DropdownMenu.tsx create mode 100644 components/ui/EmptyState.tsx create mode 100644 components/ui/Icon.tsx create mode 100644 components/ui/LikeCountDisplay.tsx create mode 100644 components/ui/LoadingSpinner.tsx create mode 100644 components/ui/PaginationBar.tsx create mode 100644 components/ui/SearchBar.tsx create mode 100644 declarations.d.ts create mode 100644 hooks/useViewport.ts delete mode 100644 next.config.js create mode 100644 next.config.mjs create mode 100644 pages/addArticle/index.tsx create mode 100644 pages/additem/index.tsx delete mode 100644 pages/api/hello.ts create mode 100644 pages/boards/[id].tsx create mode 100644 pages/boards/index.tsx create mode 100644 pages/items/[id].tsx create mode 100644 pages/items/index.tsx create mode 100644 public/images/home/bottom-banner-image.png create mode 100644 public/images/home/feature1-image.png create mode 100644 public/images/home/feature2-image.png create mode 100644 public/images/home/feature3-image.png create mode 100644 public/images/home/hero-image.png create mode 100644 public/images/icons/arrow_left.svg create mode 100644 public/images/icons/arrow_right.svg create mode 100644 public/images/icons/eye-invisible.svg create mode 100644 public/images/icons/eye-visible.svg create mode 100644 public/images/icons/ic_back.svg create mode 100644 public/images/icons/ic_heart.svg create mode 100644 public/images/icons/ic_kebab.svg create mode 100644 public/images/icons/ic_medal.svg create mode 100644 public/images/icons/ic_plus.svg create mode 100644 public/images/icons/ic_search.svg create mode 100644 public/images/icons/ic_sort.svg create mode 100644 public/images/icons/ic_x.svg create mode 100644 public/images/logo/logo.svg create mode 100644 public/images/social/facebook-logo.svg create mode 100644 public/images/social/google-logo.png create mode 100644 public/images/social/instagram-logo.svg create mode 100644 public/images/social/kakao-logo.png create mode 100644 public/images/social/twitter-logo.svg create mode 100644 public/images/social/youtube-logo.svg create mode 100644 public/images/ui/empty-comments.svg create mode 100644 public/images/ui/ic_profile.svg delete mode 100644 public/next.svg delete mode 100644 public/vercel.svg create mode 100644 styles/BoardsStyles.ts create mode 100644 styles/CommonStyles.ts create mode 100644 styles/GlobalStyle.ts delete mode 100644 styles/Home.module.css create mode 100644 styles/MarketStyles.ts delete mode 100644 styles/globals.css create mode 100644 styles/theme.ts create mode 100644 types/articleTypes.ts create mode 100644 types/commentTypes.ts create mode 100644 types/productTypes.ts create mode 100644 utils/dateUtils.ts diff --git a/.babelrc b/.babelrc new file mode 100644 index 000000000..854cb73a8 --- /dev/null +++ b/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": ["next/babel"], + "plugins": [["styled-components", { "ssr": true }]] +} diff --git a/.gitignore b/.gitignore index 8f322f0d8..fd3dbb571 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /node_modules /.pnp .pnp.js +.yarn/install-state.gz # testing /coverage diff --git a/components/boards/AllArticlesSection.tsx b/components/boards/AllArticlesSection.tsx new file mode 100644 index 000000000..40034e82b --- /dev/null +++ b/components/boards/AllArticlesSection.tsx @@ -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 = ({ article }) => { + const dateString = format(article.createdAt, "yyyy. MM. dd"); + + return ( + <> + + + {article.title} + {article.image && ( + + {/* Next Image의 width, height을 설정해줄 것이 아니라면 부모 div 내에서 fill, objectFit 설정으로 비율 유지하면서 유연하게 크기 조정 */} + {/* 프로젝트 내에 있는 이미지 파일을 사용하는 게 아니라면 next.config.mjs에 이미지 주소 설정 필요 */} + + {`${article.id}번 + + + )} + + + + + {/* ProfilePlaceholder 아이콘의 SVG 파일에서 고정된 width, height을 삭제했어요 */} + {/* */} + {article.writer.nickname} {dateString} + + + + + + + + + ); +}; + +const AddArticleLink = styled(StyledLink)``; + +interface AllArticlesSectionProps { + initialArticles: Article[]; +} + +const AllArticlesSection: React.FC = ({ + initialArticles, +}) => { + const [orderBy, setOrderBy] = useState("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 ( +
+ + 게시글 + {/* 참고: 임의로 /addArticle 이라는 pathname으로 게시글 작성 페이지를 추가했어요 */} + 글쓰기 + + + + + + + + {articles.length + ? articles.map((article) => ( + + )) + : // 참고: 요구사항에는 없었지만 항상 Empty State UI 구현하는 걸 잊지 마세요! Empty State을 재사용 가능한 컴포넌트로 만들었어요. + // 키워드가 입력되지 않은 상태에서 검색 시 Empty State이 보이지 않도록 조건 추가 + keyword && ( + + )} +
+ ); +}; + +export default AllArticlesSection; diff --git a/components/boards/BestArticlesSection.tsx b/components/boards/BestArticlesSection.tsx new file mode 100644 index 000000000..d6c7ebd70 --- /dev/null +++ b/components/boards/BestArticlesSection.tsx @@ -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 ( + + + + Best + + + + + {article.title} + {article.image && ( + + {/* Next Image의 width, height을 설정해줄 것이 아니라면 부모 div 내에서 fill, objectFit 설정으로 비율 유지하면서 유연하게 크기 조정 */} + {/* 프로젝트 내에 있는 이미지 파일을 사용하는 게 아니라면 next.config.mjs에 이미지 주소 설정 필요 */} + + {`${article.id}번 + + + )} + + + + + {article.writer.nickname} + + + {dateString} + + + + ); +}; + +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([]); + const [pageSize, setPageSize] = useState(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 ( +
+ + 베스트 게시글 + + + + {articles.map((article) => ( + + ))} + +
+ ); +}; + +export default BestArticlesSection; diff --git a/components/items/itemPage/CommentThread.tsx b/components/items/itemPage/CommentThread.tsx new file mode 100644 index 000000000..5457fe76e --- /dev/null +++ b/components/items/itemPage/CommentThread.tsx @@ -0,0 +1,147 @@ +import { useEffect, useState } from "react"; +import { getProductComments } from "@/api/itemApi"; +import styled from "styled-components"; +import SeeMoreIcon from "@/public/images/icons/ic_kebab.svg"; +import DefaultProfileImage from "@/public/images/ui/ic_profile.svg"; +import { LineDivider } from "@/styles/CommonStyles"; +import { formatUpdatedAt } from "@/utils/dateUtils"; +import { + ProductComment, + ProductCommentListResponse, +} from "@/types/commentTypes"; +import EmptyState from "@/components/ui/EmptyState"; + +const CommentContainer = styled.div` + padding: 24px 0; + position: relative; +`; + +const SeeMoreButton = styled.button` + position: absolute; + right: 0; +`; + +const CommentContent = styled.p` + font-size: 16px; + line-height: 140%; + margin-bottom: 24px; +`; + +const AuthorProfile = styled.div` + display: flex; + align-items: center; + gap: 8px; +`; + +const UserProfileImage = styled.img` + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; +`; + +const Username = styled.p` + color: var(--gray-600); + font-size: 14px; + margin-bottom: 4px; +`; + +const Timestamp = styled.p` + color: ${({ theme }) => theme.colors.gray[400]}; + font-size: 12px; +`; + +interface CommentItemProps { + item: ProductComment; +} + +const CommentItem: React.FC = ({ item }) => { + const authorInfo = item.writer; + const formattedTimestamp = formatUpdatedAt(item.updatedAt); + + return ( + <> + + {/* 참고: 더보기 버튼 기능은 추후 요구사항에 따라 추가 예정 */} + + + + + {item.content} + + + + +
+ {authorInfo.nickname} + {formattedTimestamp} +
+
+
+ + + + ); +}; + +const ThreadContainer = styled.div` + margin-bottom: 40px; +`; + +interface CommentThreadProps { + productId: number; +} + +const CommentThread: React.FC = ({ productId }) => { + const [comments, setComments] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!productId) return; + + const fetchComments = async () => { + setIsLoading(true); + + try { + const response: ProductCommentListResponse = await getProductComments({ + productId, + }); + setComments(response.list); + setError(null); + } catch (error) { + console.error("Error fetching comments:", error); + setError("상품의 댓글을 불러오지 못했어요."); + } finally { + setIsLoading(false); + } + }; + + fetchComments(); + }, [productId]); + + if (isLoading) { + return
상품 댓글 로딩중...
; + } + + if (error) { + return
오류: {error}
; + } + + if (comments && !comments.length) { + return ; + } else { + return ( + + {comments.map((item) => ( + + ))} + + ); + } +}; + +export default CommentThread; diff --git a/components/items/itemPage/ItemCommentSection.tsx b/components/items/itemPage/ItemCommentSection.tsx new file mode 100644 index 000000000..ef213e3a9 --- /dev/null +++ b/components/items/itemPage/ItemCommentSection.tsx @@ -0,0 +1,94 @@ +import { ChangeEvent, useState } from "react"; +import styled from "styled-components"; +import { Button } from "@/styles/CommonStyles"; +import CommentThread from "./CommentThread"; + +const COMMENT_PLACEHOLDER = + "개인정보를 공유 및 요청하거나, 명예 훼손, 무단 광고, 불법 정보 유포시 모니터링 후 삭제될 수 있으며, 이에 대한 민형사상 책임은 게시자에게 있습니다."; + +const CommentInputSection = styled.section` + display: flex; + flex-direction: column; + gap: 16px; +`; + +const SectionTitle = styled.h1` + font-size: 16px; + font-weight: 600; +`; + +// TODO: InputItem 컴포넌트의 textarea와 겹치므로 common styles에 추가할 것 +const TextArea = styled.textarea` + background-color: ${({ theme }) => theme.colors.gray[100]}; + border: none; + border-radius: 12px; + padding: 16px 24px; + height: 104px; + resize: none; + + &::placeholder { + color: ${({ theme }) => theme.colors.gray[400]}; + font-size: 14px; + line-height: 24px; + + @media ${({ theme }) => theme.mediaQuery.tablet} { + font-size: 16px; + } + } + + &:focus { + outline-color: ${({ theme }) => theme.colors.blue.primary}; + } +`; + +const PostCommentButton = styled(Button)` + align-self: flex-end; + font-weight: 600; + font-size: 14px; + + @media ${({ theme }) => theme.mediaQuery.tablet} { + font-size: 16px; + } +`; + +interface ItemCommentSectionProps { + productId: number; +} + +const ItemCommentSection: React.FC = ({ + productId, +}) => { + const [comment, setComment] = useState(""); + + const handleInputChange = (e: ChangeEvent) => { + setComment(e.target.value); + }; + + const handlePostComment = () => {}; + + return ( + <> + + 문의하기 + +