From c6f1e51a2e31391896e4f9dc31716a72053f316c Mon Sep 17 00:00:00 2001 From: gjrefa9139 Date: Fri, 16 Aug 2024 23:34:00 +0900 Subject: [PATCH] =?UTF-8?q?[=EC=A1=B0=EA=B7=9C=EC=A7=84]=20Sprint10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/pull_request_template.md | 21 +- UI/UI.module.scss | 20 + {layout => UI}/index.tsx | 11 +- UI/layout/GNB.module.scss | 61 + UI/layout/GNB.tsx | 39 + api/articleApi.ts | 40 + api/itemApi.ts | 61 + components/AllPost.module.scss | 55 +- components/AllPost.tsx | 93 - components/AllPostSection.tsx | 106 + .../{BestPost.tsx => BestPostSection.tsx} | 12 +- components/Board_id/CommentItem.module.scss | 32 + components/Board_id/CommentItem.tsx | 40 + .../Board_id/CommentSection.module.scss | 63 + components/Board_id/CommentSection.tsx | 35 + components/Board_id/CommentThread.module.scss | 4 + components/Board_id/CommentThread.tsx | 41 + components/Board_id/ContentSection.tsx | 9 + components/ImageUpload.module.scss | 90 + components/ImageUpload.tsx | 45 + components/layout/DeleteButton.module.scss | 5 + components/layout/DeleteButton.tsx | 19 + .../layout}/DropdownMenu.module.scss | 2 +- .../layout}/DropdownMenu.tsx | 5 +- .../layout}/EmptyState.module.scss | 0 {layout => components/layout}/EmptyState.tsx | 0 components/layout/InputItem.module.scss | 43 + components/layout/InputItem.tsx | 26 + components/layout/SearchBar.module.scss | 23 + components/layout/SearchBar.tsx | 45 + layout/Header.module.scss | 56 - layout/Header.tsx | 37 - layout/SearchBar.tsx | 5 - next.config.js | 23 + package-lock.json | 3270 ++++++++++++++++- package.json | 1 + pages/_app.tsx | 8 +- pages/addboard/index.tsx | 47 + pages/board/[id].tsx | 65 + pages/boards/index.tsx | 14 +- public/image/badge.png | Bin 1355 -> 5167 bytes styles/Addboard.module.scss | 37 + styles/Board_id.module.scss | 22 + styles/boards.module.scss | 3 +- styles/config.scss | 26 +- styles/{global.css => global.scss} | 6 + tsconfig.json | 2 +- types/article.ts | 43 + types/declaration.d.ts | 10 + 49 files changed, 4407 insertions(+), 314 deletions(-) create mode 100644 UI/UI.module.scss rename {layout => UI}/index.tsx (52%) create mode 100644 UI/layout/GNB.module.scss create mode 100644 UI/layout/GNB.tsx create mode 100644 api/articleApi.ts create mode 100644 api/itemApi.ts delete mode 100644 components/AllPost.tsx create mode 100644 components/AllPostSection.tsx rename components/{BestPost.tsx => BestPostSection.tsx} (86%) create mode 100644 components/Board_id/CommentItem.module.scss create mode 100644 components/Board_id/CommentItem.tsx create mode 100644 components/Board_id/CommentSection.module.scss create mode 100644 components/Board_id/CommentSection.tsx create mode 100644 components/Board_id/CommentThread.module.scss create mode 100644 components/Board_id/CommentThread.tsx create mode 100644 components/Board_id/ContentSection.tsx create mode 100644 components/ImageUpload.module.scss create mode 100644 components/ImageUpload.tsx create mode 100644 components/layout/DeleteButton.module.scss create mode 100644 components/layout/DeleteButton.tsx rename {layout => components/layout}/DropdownMenu.module.scss (97%) rename {layout => components/layout}/DropdownMenu.tsx (88%) rename {layout => components/layout}/EmptyState.module.scss (100%) rename {layout => components/layout}/EmptyState.tsx (100%) create mode 100644 components/layout/InputItem.module.scss create mode 100644 components/layout/InputItem.tsx create mode 100644 components/layout/SearchBar.module.scss create mode 100644 components/layout/SearchBar.tsx delete mode 100644 layout/Header.module.scss delete mode 100644 layout/Header.tsx delete mode 100644 layout/SearchBar.tsx create mode 100644 pages/addboard/index.tsx create mode 100644 pages/board/[id].tsx create mode 100644 styles/Addboard.module.scss create mode 100644 styles/Board_id.module.scss rename styles/{global.css => global.scss} (94%) create mode 100644 types/declaration.d.ts diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 4b24353e3..21df7587a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -7,15 +7,24 @@ ### 기본 -- [x] 자유 게시판 페이지 주소는 “/boards” 입니다. -- [x] 전체 게시글에서 드롭 다운으로 “최신 순” 또는 “좋아요 순”을 선택해서 정렬을 할 수 있습니다. -- [x] 게시글 목록 조회 api를 사용하여 베스트 게시글, 게시글을 구현합니다. -- [ ] 게시글 title에 검색어가 일부 포함되면 검색이 됩니다. +#### 상품 등록 페이지 + +- [x] 상품 등록 페이지 주소는 “/addboard” 입니다. +- [x] 게시판 이미지는 최대 한개 업로드가 가능합니다. +- [x] 각 input의 placeholder 값을 정확히 입력해주세요. +- [x] 이미지를 제외하고 input 에 모든 값을 입력하면 ‘등록' 버튼이 활성화 됩니다. +- [ ] 회원가입, 로그인 api를 사용하여 받은 accessToken을 사용하여 게시물 등록을 합니다. +- [ ] ‘등록’ 버튼을 누르면 게시물 상세 페이지로 이동합니다. + +#### 상품 상세 페이지 + +- [x] 상품 상세 페이지 주소는 “/board/{id}” 입니다. +- [ ] 댓글 input 값을 입력하면 ‘등록' 버튼이 활성화 됩니다. +- [ ] 활성화된 ‘등록' 버튼을 누르면 댓글이 등록됩니다 ### 심화 -- [ ] 반응형으로 보여지는 베스트 게시판 개수를 다르게 설정할때 서버에 보내는 pageSize값을 적절하게 설정합니다. -- [ ] next의 prefetch 기능을 사용해봅니다. +- ## 주요 변경사항 diff --git a/UI/UI.module.scss b/UI/UI.module.scss new file mode 100644 index 000000000..115070a4a --- /dev/null +++ b/UI/UI.module.scss @@ -0,0 +1,20 @@ +@import '../styles/config.scss'; + +.main { + margin-top: var(--header-height); + padding: 16px; + + @include media($tablet) { + padding: 16px 24px; + } + + @include media($pc) { + margin: var(--header-height) auto; + padding: 24px; + max-width: 1248px; + } +} + +.auth { + max-width: 1248px; +} diff --git a/layout/index.tsx b/UI/index.tsx similarity index 52% rename from layout/index.tsx rename to UI/index.tsx index e86545524..5b148e459 100644 --- a/layout/index.tsx +++ b/UI/index.tsx @@ -1,21 +1,22 @@ import { ReactNode } from 'react'; -import Header from './Header'; +import s from './UI.module.scss'; +import GNB from './layout/GNB'; import { useRouter } from 'next/router'; type layout = { children: ReactNode; }; -function Layout({ children }: layout) { +function UI({ children }: layout) { const router = useRouter(); const isAuthPage = router.pathname === ('/login' || '/signup'); return ( <> - {!isAuthPage &&
} -
{children}
+ {!isAuthPage && } +
{children}
); } -export default Layout; +export default UI; diff --git a/UI/layout/GNB.module.scss b/UI/layout/GNB.module.scss new file mode 100644 index 000000000..eca20a3b1 --- /dev/null +++ b/UI/layout/GNB.module.scss @@ -0,0 +1,61 @@ +@import '../../styles/config.scss'; + +.wrap { + position: fixed; + top: 0; + z-index: 1; + + width: 100%; + background-color: #fff; + border-bottom: 1px solid #dfdfdf; + + .contain { + display: flex; + justify-content: space-between; + align-items: center; + + max-width: 1600px; + height: var(--header-height); + padding: 0 16px; + + nav { + display: flex; + list-style: none; + gap: 8px; + font-weight: bold; + font-size: 16px; + color: var(--gray600); + margin-left: 16px; + flex-grow: 1; + + :hover { + color: var(--blue); + } + } + + @include media($tablet) { + padding: 0 24px; + + nav { + gap: 36px; + font-size: 18px; + margin-left: 20px; + } + } + + @include media($pc) { + margin: auto; + + nav { + margin-left: 32px; + } + } + + .login { + font-size: 16px; + font-weight: 600; + border-radius: 8px; + padding: 14.5px 43px; + } + } +} diff --git a/UI/layout/GNB.tsx b/UI/layout/GNB.tsx new file mode 100644 index 000000000..6c496583d --- /dev/null +++ b/UI/layout/GNB.tsx @@ -0,0 +1,39 @@ +import s from './GNB.module.scss'; +import logo from '@/public/logo/icon_logo.png'; +import Image from 'next/image'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; + +function GNB() { + const { pathname } = useRouter(); + + function getLinkStyle(isActive: boolean) { + return { color: isActive ? 'var(--blue)' : undefined }; + } + + return ( +
+
+ + 판다마켓 로고 + + + + + + 로그인 + +
+
+ ); +} + +export default GNB; diff --git a/api/articleApi.ts b/api/articleApi.ts new file mode 100644 index 000000000..117269d59 --- /dev/null +++ b/api/articleApi.ts @@ -0,0 +1,40 @@ +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; + } +} diff --git a/api/itemApi.ts b/api/itemApi.ts new file mode 100644 index 000000000..8ff5eb84f --- /dev/null +++ b/api/itemApi.ts @@ -0,0 +1,61 @@ +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; + } +} diff --git a/components/AllPost.module.scss b/components/AllPost.module.scss index 9196bbcce..83a28991e 100644 --- a/components/AllPost.module.scss +++ b/components/AllPost.module.scss @@ -1,32 +1,37 @@ @import '../styles/config.scss'; -.content { - display: flex; - gap: 8px; - min-height: 72px; +.wrap { + border-bottom: 1px solid var(--gray200); + padding-bottom: 24px; - h3 { - font-size: 18px; - font-weight: 600; - flex: 1; + .content { + display: flex; + min-height: 72px; + gap: 8px; - @include media($tablet) { - font-size: 20px; + h3 { + font-size: 18px; + font-weight: 600; + flex: 1; + + @include media($tablet) { + font-size: 20px; + } } - } - .thumbnail { - background-color: #fff; - border: 1px solid var(--gray200); - width: 72px; - height: 72px; - border-radius: 8px; - padding: 12px; + .thumbnail { + background-color: #fff; + border: 1px solid var(--gray200); + width: 72px; + height: 72px; + border-radius: 8px; + padding: 12px; - .img { - width: 100%; - height: 100%; - position: relative; + .img { + width: 100%; + height: 100%; + position: relative; + } } } } @@ -82,3 +87,9 @@ cursor: pointer; } } + +.posts { + display: flex; + flex-direction: column; + gap: 24px; +} diff --git a/components/AllPost.tsx b/components/AllPost.tsx deleted file mode 100644 index 48de9c9ff..000000000 --- a/components/AllPost.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { useEffect, useState } from 'react'; -import s from './AllPost.module.scss'; -import SearchBar from '@/layout/SearchBar'; -import DropdownMenu from '@/layout/DropdownMenu'; -import EmptyState from '@/layout/EmptyState'; -import Image from 'next/image'; -import Link from 'next/link'; -import { useRouter } from 'next/router'; - -function ArticleItem({ article }: { article: Article }) { - return ( - <> - -
-

{article.title}

- {article.image && ( -
-
- {`${article.id}번 -
-
- )} -
- -
-
{article.writer.nickname}
-
- - -
- - ); -} - -function AllPost({ initialArticles }: { initialArticles: Article[] }) { - 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); - }; - - useEffect(() => { - const fetchArticles = async () => { - let url = `https://panda-market-api.vercel.app/articles?orderBy=${orderBy}`; - if (keyword.trim()) { - url += `&keyword=${encodeURIComponent(keyword)}`; - } - const response = await fetch(url); - const data = await response.json(); - setArticles(data.list); - }; - - fetchArticles(); - }, [orderBy, keyword]); - - return ( -
-
-

게시글

- - 글쓰기 - -
- -
- - - -
- - {articles.length - ? articles.map((article) => ) - : keyword && } -
- ); -} - -export default AllPost; diff --git a/components/AllPostSection.tsx b/components/AllPostSection.tsx new file mode 100644 index 000000000..43d686fba --- /dev/null +++ b/components/AllPostSection.tsx @@ -0,0 +1,106 @@ +import { useEffect, useState } from 'react'; +import s from './AllPost.module.scss'; +import SearchBar from '@/components/layout/SearchBar'; +import DropdownMenu from '@/components/layout/DropdownMenu'; +import EmptyState from '@/components/layout/EmptyState'; +import Image from 'next/image'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; + +function Post({ post }: { post: Article }) { + return ( + <> + +
+
+

{post.title}

+ + {post.image && ( +
+
+ {`${post.id}번 +
+
+ )} +
+ +
+
{post.writer.nickname}
+
+
+ + + ); +} + +function AllPostSection({ initialArticles }: { initialArticles: Article[] }) { + 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; + } + + router.replace({ + pathname: router.pathname, + query, + }); + }; + + useEffect(() => { + const fetchArticles = async () => { + let url = `https://panda-market-api.vercel.app/articles?orderBy=${orderBy}`; + if (keyword.trim()) { + url += `&keyword=${encodeURIComponent(keyword)}`; + } + const response = await fetch(url); + const data = await response.json(); + setArticles(data.list); + }; + + fetchArticles(); + }, [orderBy, keyword]); + + return ( +
+
+

게시글

+ + 글쓰기 + +
+ +
+ + + +
+ +
+ {articles.length + ? articles.map((post) => ) + : keyword && } +
+
+ ); +} + +export default AllPostSection; diff --git a/components/BestPost.tsx b/components/BestPostSection.tsx similarity index 86% rename from components/BestPost.tsx rename to components/BestPostSection.tsx index 799a20e5a..6d27d5d9b 100644 --- a/components/BestPost.tsx +++ b/components/BestPostSection.tsx @@ -5,11 +5,11 @@ import useViewport from '@/hooks/useViewport'; import Image from 'next/image'; import Link from 'next/link'; -function BestPostCard({ article }: { article: Article }) { +function BestPost({ article }: { article: Article }) { return ( - +
- 베스트 게시글 + 베스트 게시글
@@ -45,7 +45,7 @@ function getPageSize(width: number) { return width < TABLET ? 1 : width < PC ? 2 : 3; } -function BestPost() { +function BestPostSection() { const [articles, setArticles] = useState([]); const [pageSize, setPageSize] = useState(null); @@ -82,11 +82,11 @@ function BestPost() {
{articles.map((article) => ( - + ))}
); } -export default BestPost; +export default BestPostSection; diff --git a/components/Board_id/CommentItem.module.scss b/components/Board_id/CommentItem.module.scss new file mode 100644 index 000000000..f496e4325 --- /dev/null +++ b/components/Board_id/CommentItem.module.scss @@ -0,0 +1,32 @@ +@import '../../styles/config.scss'; + +.contain { + padding: 0 0 20px; + position: relative; + border-bottom: 1px solid var(--gray200); + + .seeMore { + position: absolute; + right: 0; + } + + > p { + font-size: 16px; + line-height: 24px; + margin-bottom: 24px; + color: var(--gray800); + } + + .profile { + display: flex; + align-items: center; + gap: 8px; + + .username { + font-size: 12px; + font-weight: 400; + line-height: 18px; + color: var(--gray600); + } + } +} diff --git a/components/Board_id/CommentItem.tsx b/components/Board_id/CommentItem.tsx new file mode 100644 index 000000000..7e3ed2295 --- /dev/null +++ b/components/Board_id/CommentItem.tsx @@ -0,0 +1,40 @@ +import s from './CommentItem.module.scss'; +import KebabIcon from '@/public/svg/ic_kebab.svg'; +import DefaultProfile from '@/public/svg/ic_profile.svg'; +import Image from 'next/image'; + +type CommentItem = { + item: Comments; +}; + +function CommentItem({ item }: CommentItem) { + const authorInfo = item.writer; + + return ( +
+ + +

{item.content}

+ +
+ {authorInfo.image ? ( + {`${authorInfo.nickname}님의 + ) : ( + + )} + +

{authorInfo.nickname}

+
+
+ ); +} + +export default CommentItem; diff --git a/components/Board_id/CommentSection.module.scss b/components/Board_id/CommentSection.module.scss new file mode 100644 index 000000000..7d9b8eb12 --- /dev/null +++ b/components/Board_id/CommentSection.module.scss @@ -0,0 +1,63 @@ +@import '../../styles/config.scss'; + +.commentInput { + display: flex; + flex-direction: column; + gap: 16px; + + margin-bottom: 24px; + + @include media($tablet) { + margin-bottom: 32px; + } + + @include media($pc) { + margin-bottom: 40px; + } + + h3 { + font-size: 16px; + font-weight: 600; + line-height: 26px; + color: var(--gray900); + } + + textarea { + background-color: var(--gray100); + border: none; + border-radius: 12px; + padding: 16px 24px; + height: 104px; + resize: none; + + &::placeholder { + color: var(--gray400); + font-size: 14px; + line-height: 24px; + + @include media($tablet) { + font-size: 16px; + } + } + + &:focus { + outline-color: var(--blue); + } + } + + > button { + @include button; + align-self: flex-end; + + padding: 8px; + width: 74px; + + font-size: 16px; + font-weight: 600; + line-height: 26px; + + @include media($tablet) { + font-size: 16px; + } + } +} diff --git a/components/Board_id/CommentSection.tsx b/components/Board_id/CommentSection.tsx new file mode 100644 index 000000000..a25b546b4 --- /dev/null +++ b/components/Board_id/CommentSection.tsx @@ -0,0 +1,35 @@ +import { ChangeEvent, useState } from 'react'; +import s from './CommentSection.module.scss'; +import CommentThread from './CommentThread'; + +type CommentSection = { + articleId: number; +}; + +function CommentSection({ articleId }: CommentSection) { + const [comment, setComment] = useState(''); + + const handleInputChange = (e: ChangeEvent) => { + setComment(e.target.value); + }; + + const handlePostComment = () => {}; + + return ( + <> +
+

댓글 달기

+ +