diff --git a/.gitignore b/.gitignore index 5ef6a5207..f060dedd4 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ # testing /coverage +request.http # next.js /.next/ diff --git a/README.md b/README.md index f5aaf6026..f44d27ed4 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,45 @@ **코드잇 12기 스프린트 내용입니다.** +## 10주차 스프린트 + +### 댓글 추가에 따른 revalidate 관련 이슈 +- 댓글 리스트 컴포넌트를 react-query를 사용하기 위해 클라이언트 컴포넌트로 사용했다. +- 폼 제출을 한 후 revalidate를 강제로 페이지 단에서 하도록 서버액션을 실행시켰는데, 전혀 리스트가 최신화되지 않았다. +- 원인을 찾아보니, 서버액션에서 revalidate를 강제 요청하는 것은 서버 컴포넌트만 해당 된다는 것이었다. +- 이미 리액트 쿼리로 리스트를 구현하였기 때문에 수정할 수는 없었고, POST 요청을 성공적으로 했을 경우, 기존 쿼리 key에 있는 데이터 캐시를 초기화하였더니 해결 되었다. +```js +// onSubmit 핸들러 안에서 POST 요청 후 성공적으로 수행했을 경우 쿼리 키 기반으로 캐싱 해제 +if (result.success) { + queryClient.invalidateQueries({ + queryKey: ['article-comments', Number(id)], + }); +} +``` + +### react-intersection-observer 감지오류 이슈 +- 리스트 컴포넌트 부분을 무한 스크롤링으로 구현하는 도중 옵저버가 감지가 안되는 경우가 있었다. +- 왜 그런 지 분석하였더니, 이 옵저버는 요소가 뷰포트 영역에 안보임 -> 보임, 보임 -> 안보임 상태여야만 트리거가 되는데, limit값이 너무 작아서 보임 -> 보임 상태로만 유지되어 추가로 페칭이 되지 않은 것이었다. +- 이 부분은 사용자 디바이스 높이에 따라 limit값을 동적으로 설정했다. + +### list 구현 +- 이번에 새롭게 @tanstack/react-query 라이브러리를 사용하여 서버 상태관리를 해보았다. +- 활용해보니, 첫 리스트 페칭 로딩 상태와 향후 로딩 상태를 구분하기 쉬워서 초기에는 스켈레톤 UI로, 그 이후 데이터들은 로딩 스피너로 처리하였다. + +### Form 상태 +- 이번 미션에는 게시글 생성과 게시글 별 댓글 생성으로 총 2개의 폼이 필요했다. +- 리액트 훅 폼으로 처리할 지 useActionState로 처리할 지 고민했는데, 리액트 훅 폼으로 구현한 경험이 없어서 리액트 훅 폼으로 처리했다. +- useActionState를 쓰는 방식보다 코드가 더 길어지긴 했지만, 클라이언트 측에서 Form Valid 검사할 때 더 쉬웠다. +- onSubmit으로 NEXT의 서버 액션을 활용하여 폼을 제출하도록 구현했다. + +### Token 처리 +- accessToken과 refreshToken을 판다마켓 API에서 response로 주기 때문에 해당 값을 우선 로컬스토리지에 저장해 놨다. + - POST 요청의 경우 사용자 식별을 위해 accessToken이 필요하다. +- POST 요청을 할 때 우선, accessToken을 Header에 부착하여 보낸다. +- acceessToken이 만료되면 가지고 있는 refreshToken으로 accessToken을 요청한다. + - refreshToken조차 만료되면 로그인 페이지로 유도하도록 구현했다. +- 응답 받은 accessToken으로 POST 요청을 다시한다. + ## 9주차 스프린트 ### 실수했던 부분 diff --git a/package-lock.json b/package-lock.json index eafea2d1f..a2aafb934 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,15 @@ "version": "0.1.0", "dependencies": { "@fontsource/pretendard": "^5.1.0", + "@tanstack/react-query": "^5.64.1", "date-fns": "^4.1.0", "dayjs": "^1.11.13", + "es-toolkit": "^1.31.0", "next": "15.1.3", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "react-hook-form": "^7.54.2", + "react-intersection-observer": "^9.15.0" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -853,6 +857,30 @@ "tslib": "^2.8.0" } }, + "node_modules/@tanstack/query-core": { + "version": "5.64.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.64.1.tgz", + "integrity": "sha512-978Wx4Wl4UJZbmvU/rkaM9cQtXXrbhK0lsz/UZhYIbyKYA8E4LdomTwyh2GHZ4oU0BKKoDH4YlKk2VscCUgNmg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.64.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.64.1.tgz", + "integrity": "sha512-vW5ggHpIO2Yjj44b4sB+Fd3cdnlMJppXRBJkEHvld6FXh3j5dwWJoQo7mGtKI2RbSFyiyu/PhGAy0+Vv5ev9Eg==", + "dependencies": { + "@tanstack/query-core": "5.64.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -2057,6 +2085,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.31.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.31.0.tgz", + "integrity": "sha512-vwS0lv/tzjM2/t4aZZRAgN9I9TP0MSkWuvt6By+hEXfG/uLs8yg2S1/ayRXH/x3pinbLgVJYT+eppueg3cM6tg==" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -4308,6 +4341,35 @@ "react": "^19.0.0" } }, + "node_modules/react-hook-form": { + "version": "7.54.2", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.54.2.tgz", + "integrity": "sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-intersection-observer": { + "version": "9.15.0", + "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.15.0.tgz", + "integrity": "sha512-qul9TzGgZtHIHAsLOXnRfMWNYCrqjU87HMKhRjwC8l6XSxz2Bo0xmpq5pklaXGj+brx2gSMe8lp1K17mMP2Q8w==", + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/package.json b/package.json index 1c2c79d2e..0e88338df 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,15 @@ }, "dependencies": { "@fontsource/pretendard": "^5.1.0", + "@tanstack/react-query": "^5.64.1", "date-fns": "^4.1.0", "dayjs": "^1.11.13", + "es-toolkit": "^1.31.0", "next": "15.1.3", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "react-hook-form": "^7.54.2", + "react-intersection-observer": "^9.15.0" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/public/assets/icons/back_symbol.svg b/public/assets/icons/back_symbol.svg new file mode 100644 index 000000000..253a47d7b --- /dev/null +++ b/public/assets/icons/back_symbol.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/assets/icons/plus.svg b/public/assets/icons/plus.svg new file mode 100644 index 000000000..5bb9abf55 --- /dev/null +++ b/public/assets/icons/plus.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/assets/images/Img_reply_empty.png b/public/assets/images/Img_reply_empty.png new file mode 100644 index 000000000..b74f5fd1b Binary files /dev/null and b/public/assets/images/Img_reply_empty.png differ diff --git a/public/assets/images/auth-pages-logo.png b/public/assets/images/auth-pages-logo.png new file mode 100644 index 000000000..37a5a9968 Binary files /dev/null and b/public/assets/images/auth-pages-logo.png differ diff --git a/src/actions/submit-article.ts b/src/actions/submit-article.ts new file mode 100644 index 000000000..2c845f79a --- /dev/null +++ b/src/actions/submit-article.ts @@ -0,0 +1,59 @@ +'use server'; + +export async function submitArticle(formData: FormData, accessToken: string | null, refreshToken: string | null) { + if (!accessToken || !refreshToken) { + return { success: false, message: '로그인이 필요합니다.' }; + } + const formDataObject = Object.fromEntries(formData.entries()); + + try { + const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/articles`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify(formDataObject), + }); + + if (response.ok) { + const { id }: { id: number } = await response.json(); + return { success: true, message: '게시글 생성이 완료되어 3초 후 페이지를 이동합니다.', id }; + } + if (response.status === 401) { + const refreshResponse = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/refresh-token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ refreshToken }), + }); + if (refreshResponse.ok) { + const { accessToken: newAccessToken } = await refreshResponse.json(); + + const retryResponse = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/articles`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${newAccessToken}`, + }, + body: JSON.stringify(formDataObject), + }); + + if (retryResponse.ok) { + const { id }: { id: number } = await retryResponse.json(); + return { success: true, message: '게시글 생성이 완료되어 3초 후 페이지를 이동합니다.', accessToken: newAccessToken, id }; + } else { + return { success: false, message: '게시글 생성 중 오류가 발생했습니다.' }; + } + } + + return { success: false, message: '세션이 만료되었습니다. 다시 로그인해주세요.' }; + } + + return { success: false, message: '게시글 생성 중 오류가 발생했습니다.' }; + } catch (error) { + console.log(error); + return { success: false, message: '서버 요청에 실패했습니다.' }; + } +} diff --git a/src/actions/submit-comment.ts b/src/actions/submit-comment.ts new file mode 100644 index 000000000..bdf6eeb9d --- /dev/null +++ b/src/actions/submit-comment.ts @@ -0,0 +1,57 @@ +'use server'; + +export async function submitComment(formData: FormData, accessToken: string | null, refreshToken: string | null, id: number) { + if (!accessToken || !refreshToken) { + return { success: false, message: '로그인이 필요합니다.' }; + } + const formDataObject = Object.fromEntries(formData.entries()); + + try { + const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/articles/${id}/comments`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify(formDataObject), + }); + + if (response.ok) { + return { success: true, message: '댓글 등록이 완료되었습니다.' }; + } + if (response.status === 401) { + const refreshResponse = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/refresh-token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ refreshToken }), + }); + if (refreshResponse.ok) { + const { accessToken: newAccessToken } = await refreshResponse.json(); + + const retryResponse = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/articles/${id}/comments`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${newAccessToken}`, + }, + body: JSON.stringify(formDataObject), + }); + + if (retryResponse.ok) { + return { success: true, message: '댓글 등록이 완료되었습니다.', accessToken: newAccessToken }; + } else { + return { success: false, message: '댓글 등록 중 오류가 발생했습니다.' }; + } + } + + return { success: false, message: '세션이 만료되었습니다. 다시 로그인해주세요.' }; + } + + return { success: false, message: '댓글 등록 중 오류가 발생했습니다.' }; + } catch (error) { + console.log(error); + return { success: false, message: '서버 요청에 실패했습니다.' }; + } +} diff --git a/src/actions/submit-login.ts b/src/actions/submit-login.ts new file mode 100644 index 000000000..f918bbe3f --- /dev/null +++ b/src/actions/submit-login.ts @@ -0,0 +1,39 @@ +'use server'; + +import { SigninFailResponse, SigninSuccessResponse } from '@/types'; + +// 이전 상태는 사용안하므로 임시 any로 지정 +// eslint-disable-next-line +export async function submitLogin(_: any, formData: FormData) { + const email = formData.get('email')?.toString(); + const password = formData.get('password')?.toString(); + + if (!email || !password) + return { + status: false, + error: '이메일과 비밀번호를 입력해주세요', + }; + + try { + const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/signIn`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email, password }), + }); + if (!response.ok) throw new Error(response.statusText); + const responseJson: SigninSuccessResponse | SigninFailResponse = await response.json(); + return { + response: responseJson, + status: true, + error: '', + }; + } catch (err) { + console.error(err); + return { + status: false, + error: `로그인을 실패했습니다. : ${err}`, + }; + } +} diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx new file mode 100644 index 000000000..3f06ae2bd --- /dev/null +++ b/src/app/(auth)/layout.tsx @@ -0,0 +1,22 @@ +import Link from 'next/link'; +import Image from 'next/image'; +import { ReactNode } from 'react'; + +function Header() { + return ( +
+ + 인증 페이지 로고 + +
+ ); +} + +export default function Layout({ children }: { children: ReactNode }) { + return ( + <> +
+ {children} + + ); +} diff --git a/src/app/(auth)/signin/page.tsx b/src/app/(auth)/signin/page.tsx new file mode 100644 index 000000000..a5a63c461 --- /dev/null +++ b/src/app/(auth)/signin/page.tsx @@ -0,0 +1,35 @@ +import SigninForm from '@/components/Auth/SigninForm'; +import Link from 'next/link'; + +function SignupGuide() { + return ( +
+ 판다마켓이 처음이신가요? + + 회원가입 + +
+ ); +} + +function SimpleLoginContainer() { + return ( +
+ 간편 로그인하기 +
+ 구글 + 카카오 +
+
+ ); +} + +export default function Signin() { + return ( +
+ + + +
+ ); +} diff --git a/src/app/(auth)/signup/page.tsx b/src/app/(auth)/signup/page.tsx new file mode 100644 index 000000000..c184b4253 --- /dev/null +++ b/src/app/(auth)/signup/page.tsx @@ -0,0 +1,3 @@ +export default function Signup() { + return
회원가입 페이지입니다.
; +} diff --git a/src/app/(with-Header)/addboard/page.tsx b/src/app/(with-Header)/addboard/page.tsx new file mode 100644 index 000000000..f438cf117 --- /dev/null +++ b/src/app/(with-Header)/addboard/page.tsx @@ -0,0 +1,9 @@ +import ArticleForm from '@/components/ArticleForm'; + +export default function Addboard() { + return ( +
+ +
+ ); +} diff --git a/src/app/(with-Header)/board/[id]/page.tsx b/src/app/(with-Header)/board/[id]/page.tsx new file mode 100644 index 000000000..b91c194c6 --- /dev/null +++ b/src/app/(with-Header)/board/[id]/page.tsx @@ -0,0 +1,52 @@ +import { DetailArticle } from '@/types'; +import { notFound } from 'next/navigation'; +import Image from 'next/image'; +import dayjs from 'dayjs'; +import CommentForm from '@/components/CommentForm'; +import ArticleCommentList from '@/components/ArticleCommentList'; + +async function Title({ id }: { id: string }) { + const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/articles/${id}`, { + next: { + tags: [`detail-article-${id}`], + }, + }); + if (!response.ok) { + if (response.status === 404) notFound(); + } + + const article: DetailArticle = await response.json(); + const date = dayjs(article.createdAt).format('YYYY. MM. DD'); + + return ( +
+
+

{article.title}

+
+
+ 게시글 작성자 프로필 이미지 + {article.writer.nickname} + {date} +
+
+ 좋아요 이미지 + {article.likeCount} +
+
+
+

{article.content}

+
+ ); +} + +export default async function DetailBoard({ params }: { params: Promise<{ id: string }> }) { + const articleId = (await params).id; + + return ( +
+ + <CommentForm id={articleId} /> + <ArticleCommentList id={Number(articleId)} /> + </main> + ); +} diff --git a/src/app/(with-Header)/boards/page.tsx b/src/app/(with-Header)/boards/page.tsx index c31162494..a477601b2 100644 --- a/src/app/(with-Header)/boards/page.tsx +++ b/src/app/(with-Header)/boards/page.tsx @@ -1,6 +1,7 @@ import BestArticleList from '@/components/BestArticleList'; import { Suspense } from 'react'; import ArticleContent from '@/components/ArticleContent'; +import Link from 'next/link'; function SkeletonUi({ cnt }: { cnt: number }) { return ( @@ -48,7 +49,9 @@ export default function Boards() { <section className='max-w-[1200px] mt-6 mx-auto px-6'> <div className='flex items-center'> <p className='flex-1 text-xl text-gray-900 font-bold'>게시글</p> - <button className='rounded-lg py-3 px-6 bg-blue-100 font-semibold text-white'>글쓰기</button> + <Link href='/addboard' className='rounded-lg py-3 px-6 bg-blue-100 font-semibold text-white'> + 글쓰기 + </Link> </div> <Suspense fallback={ diff --git a/src/app/(with-Header)/community/page.tsx b/src/app/(with-Header)/community/page.tsx deleted file mode 100644 index aeebe8ac4..000000000 --- a/src/app/(with-Header)/community/page.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function Community() { - return <div className='mt-[90px]'>커뮤니티 페이지</div>; -} diff --git a/src/app/(with-Header)/layout.tsx b/src/app/(with-Header)/layout.tsx index 70a8719e9..621b79ddd 100644 --- a/src/app/(with-Header)/layout.tsx +++ b/src/app/(with-Header)/layout.tsx @@ -19,7 +19,7 @@ function Header() { <Link href={'/'}> <Icon /> </Link> - <Link href={'/community'}>자유게시판</Link> + <Link href={'/boards'}>자유게시판</Link> <Link href={'/items'}>중고마켓</Link> </div> <Link href={'/'}> diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a634a9ed5..d7f469d17 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import localFont from 'next/font/local'; import './globals.css'; import { Metadata } from 'next'; +import ClientProvider from '@/components/ClientProvider'; const pretendard = localFont({ src: '../fonts/PretendardVariable.woff2', @@ -29,7 +30,9 @@ export default function RootLayout({ <head> <link rel='icon' type='image/png' href='/assets/images/logo_img.png' /> </head> - <body className={`${pretendard.className} min-h-screen`}>{children}</body> + <body className={`${pretendard.className} min-h-screen`}> + <ClientProvider>{children}</ClientProvider> + </body> </html> ); } diff --git a/src/components/ArticleCommentList/index.tsx b/src/components/ArticleCommentList/index.tsx new file mode 100644 index 000000000..01dcb082c --- /dev/null +++ b/src/components/ArticleCommentList/index.tsx @@ -0,0 +1,142 @@ +'use client'; + +import React, { useEffect } from 'react'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useInView } from 'react-intersection-observer'; +import { Comment, Comments } from '@/types'; +import getDiffTime from '@/utils/getDiffTime'; +import Image from 'next/image'; +import { useRouter } from 'next/navigation'; +import { isEmpty } from 'es-toolkit/compat'; + +function EmptyCommentList() { + return ( + <div className='flex flex-col items-center text-gray-400'> + <Image src='/assets/images/Img_reply_empty.png' alt='댓글 없음 이미지' width={140} height={140} /> + <p>아직 댓글이 없어요</p> + <p>지금 댓글을 달아보세요!</p> + </div> + ); +} + +function MovePageBtn() { + const router = useRouter(); + return ( + <button className='flex items-center my-4 rounded-full py-2 px-4 bg-blue-100 mx-auto' onClick={() => router.push('/boards')}> + <span className='text-lg font-semibold text-white'>목록으로 돌아가기</span> + <Image src='/assets/icons/back_symbol.svg' alt='/boards 페이지 이동 아이콘' width={20} height={16} /> + </button> + ); +} + +const getLimit = () => { + const screenHeight = window.innerHeight; + if (screenHeight <= 600) { + return 5; + } + if (screenHeight <= 1200) { + return 10; + } + return 15; +}; + +function ArticleComment({ comment }: { comment: Comment }) { + const date = getDiffTime(comment.updatedAt); + return ( + <div className='flex flex-col gap-4 border-b py-4'> + <p className='text-sm text-gray-800'>{comment.content}</p> + <div className='flex items-center gap-2'> + <Image src={comment.writer.image ?? '/assets/icons/profile.svg'} alt='작성자 프로필 이미지' width={32} height={32} /> + <div className='flex flex-col gap-1'> + <span className='text-xs text-gray-600'>{comment.writer.nickname}</span> + <span className='text-xs text-gray-400'>{date}</span> + </div> + </div> + </div> + ); +} + +function SkeletonComment() { + return ( + <div className='flex flex-col gap-4 border-b py-4 animate-pulse'> + <div className='h-4 bg-gray-200 w-3/4 rounded'></div> + <div className='flex items-center gap-2'> + <div className='w-8 h-8 rounded-full bg-gray-200'></div> + <div className='flex flex-col gap-1'> + <div className='w-16 h-3 bg-gray-200 rounded'></div> + <div className='w-20 h-3 bg-gray-200 rounded'></div> + </div> + </div> + </div> + ); +} + +export default function ArticleCommentList({ id }: { id: number }) { + const [ref, inView] = useInView({ + threshold: 0.1, + rootMargin: '0px 0px 0px 0px', + }); + + const getComments = async (cursor?: number): Promise<Comments> => { + try { + const limit = getLimit(); + const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/articles/${id}/comments?cursor=${cursor ?? ''}&limit=${limit}`); + if (!response.ok) { + throw new Error('데이터를 가져올 수 없습니다.'); + } + return await response.json(); + } catch (e) { + console.error(e); + return { list: [], nextCursor: null }; + } + }; + + const { data, fetchNextPage, isLoading, isFetching } = useInfiniteQuery<Comments>({ + queryKey: ['article-comments', id], + queryFn: ({ pageParam = 0 }) => getComments(pageParam as number), + initialPageParam: 0, + getNextPageParam: (lastPage) => { + return lastPage.nextCursor ?? undefined; + }, + }); + + useEffect(() => { + if (inView) { + fetchNextPage(); + } + }, [inView, fetchNextPage]); + + if (isLoading) { + return ( + <div className='flex flex-col mx-auto w-[100%] max-w-[1200px] px-6'> + {Array.from({ length: 3 }, (_, i) => ( + <SkeletonComment key={i} /> + ))} + </div> + ); + } + return ( + <div className='flex flex-col mx-auto w-[100%] max-w-[1200px] px-6'> + {isEmpty(data?.pages[0]?.list) ? ( + <EmptyCommentList /> + ) : ( + data?.pages.map((page, index) => ( + <div key={index}> + {page.list.map((comment) => ( + <ArticleComment key={comment.id} comment={comment} /> + ))} + </div> + )) + )} + + {isFetching && ( + <div className='flex justify-center items-center py-4'> + <div className='w-8 h-8 border-4 border-t-4 border-gray-500 border-solid rounded-full animate-spin'></div> + </div> + )} + <MovePageBtn /> + + <div ref={ref} className='h-4'></div> + </div> + ); +} diff --git a/src/components/ArticleContent/ArticleList/ArticleItem/index.tsx b/src/components/ArticleContent/ArticleList/ArticleItem/index.tsx index 4c06919cc..dd84770f8 100644 --- a/src/components/ArticleContent/ArticleList/ArticleItem/index.tsx +++ b/src/components/ArticleContent/ArticleList/ArticleItem/index.tsx @@ -4,6 +4,7 @@ import Image from 'next/image'; import { Article } from '@/types'; import dayjs from 'dayjs'; import { useState } from 'react'; +import Link from 'next/link'; const DEFAULT_IMAGE = '/assets/icons/profile.svg'; @@ -12,7 +13,7 @@ export default function ArticleItem({ article }: { article: Article }) { const date = dayjs(article.createdAt).format('YYYY. MM. DD'); return ( - <div className='flex flex-col gap-6 rounded-lg p-6 bg-gray-50'> + <Link href={`/board/${article.id}`} className='flex flex-col gap-6 rounded-lg p-6 bg-gray-50 cursor-pointer'> <div className='flex gap-2 justify-between'> <p className='text-xl font-semibold'>{article.title}</p> <div className='shrink-0 flex justify-center items-center w-[72px] h-[72px] border rounded-md bg-white'> @@ -30,6 +31,6 @@ export default function ArticleItem({ article }: { article: Article }) { <p>{article.likeCount}</p> </div> </div> - </div> + </Link> ); } diff --git a/src/components/ArticleForm/index.tsx b/src/components/ArticleForm/index.tsx new file mode 100644 index 000000000..65d6dc9f0 --- /dev/null +++ b/src/components/ArticleForm/index.tsx @@ -0,0 +1,125 @@ +'use client'; + +import { useForm } from 'react-hook-form'; +import { submitArticle } from '@/actions/submit-article'; +import { useState } from 'react'; +import Image from 'next/image'; + +import { ArticleFormData } from '@/types'; +import { useRouter } from 'next/navigation'; + +export default function ArticleForm() { + const { + register, + handleSubmit, + formState: { isSubmitting, errors }, + watch, + reset, + } = useForm<ArticleFormData>({ + defaultValues: { + title: '', + content: '', + image: null, + }, + mode: 'onChange', + }); + const [resultMessage, setResultMessage] = useState<string | null>(null); + const [previewImage, setPreviewImage] = useState<string | null>(null); + const router = useRouter(); + + const formValues = watch(); + + const isFormValid = formValues.title && formValues.content; + + const onSubmit = async (data: ArticleFormData) => { + try { + const formData = new FormData(); + + if (data.image) formData.append('image', data.image); + + formData.append('title', data.title); + formData.append('content', data.content); + const accessToken = localStorage.getItem('accessToken'); + const refreshToken = localStorage.getItem('refreshToken'); + const result = await submitArticle(formData, accessToken, refreshToken); + setResultMessage(result.message); + reset({ title: '', content: '', image: null }); + setPreviewImage(null); + + if (result.accessToken) localStorage.setItem('accessToken', result.accessToken); + + if (result.success) { + setTimeout(() => { + router.push(`/board/${result.id}`); + }, 3000); + } + if (result.message.includes('세션')) { + router.push(`/signin`); + } + } catch (error) { + setResultMessage(error instanceof Error ? error.message : '게시글 생성 중 오류가 발생했습니다.'); + } + }; + + const preventEnterSubmit = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + } + }; + + const handleImageChange = (event: React.ChangeEvent<HTMLInputElement>) => { + const file = event.target.files ? event.target.files[0] : null; + + // 기존 미리보기 이미지 URL이 존재하면 해제 + if (previewImage) { + URL.revokeObjectURL(previewImage); + } + + if (file) { + setPreviewImage(URL.createObjectURL(file)); // 선택된 이미지의 미리보기 URL을 생성 + } + }; + + return ( + <form onSubmit={handleSubmit(onSubmit)} className='flex flex-col gap-4 mx-auto w-[100%] max-w-[1200px] px-6'> + <div className='flex justify-between items-center'> + <span className='font-bold text-xl'>게시글 쓰기</span> + <button type='submit' className={`${isSubmitting || !isFormValid ? 'bg-gray-400' : 'bg-blue-100'} text-white py-2 px-6 rounded-lg`} disabled={isSubmitting || !isFormValid}> + {isSubmitting ? '등록 중' : '등록'} + </button> + </div> + <label className='flex flex-col gap-2'> + <span className='font-bold text-lg'>*제목</span> + <input + {...register('title', { required: '제목을 반드시 입력해야 합니다.' })} + className='w-full rounded-xl py-4 px-6 bg-gray-100' + onKeyDown={preventEnterSubmit} + placeholder='제목을 입력해주세요' + /> + {errors.title && <span className='text-red-error'>{errors.title.message}</span>} + </label> + + <label className='flex flex-col gap-2'> + <span className='font-bold text-lg'>*내용</span> + <textarea + {...register('content', { required: '내용을 반드시 입력해야 합니다.' })} + className='w-full h-[120px] rounded-xl py-4 px-6 bg-gray-100 resize-none md:h-[200px]' + onKeyDown={preventEnterSubmit} + placeholder='내용을 입력해주세요' + /> + {errors.content && <span className='text-red-error'>{errors.content.message}</span>} + </label> + <span className='font-bold text-lg'>이미지</span> + <div className='flex gap-4'> + <label className='flex flex-col justify-center items-center gap-2 rounded-xl w-[168px] h-[168px] bg-gray-100 cursor-pointer md:w-[282px] md:h-[282px]'> + <Image src='/assets/icons/plus.svg' alt='이미지 등록 아이콘' width={44} height={44} style={{ width: 44, height: 44 }} /> + <span className='text-gray-400'>이미지 등록</span> + <input type='file' {...register('image')} hidden onChange={handleImageChange} /> + </label> + {previewImage && <Image src={previewImage} alt='업로드 할 이미지 미리보기' width={282} height={282} className='rounded-xl w-[168px] h-auto p-4 bg-gray-100 object-contain md:w-[282px]' />} + </div> + + {resultMessage && <p className='text-green-500'>{resultMessage}</p>} + </form> + ); +} diff --git a/src/components/ArticleSearchbar/index.tsx b/src/components/ArticleSearchbar/index.tsx index 0e12d361e..e40806e1b 100644 --- a/src/components/ArticleSearchbar/index.tsx +++ b/src/components/ArticleSearchbar/index.tsx @@ -31,11 +31,19 @@ export default function Searchbar() { }; return ( - <div className='flex-1 flex items-center gap-2 rounded-xl px-4 py-2 bg-gray-100'> + <div className='flex-1 flex items-center gap-2 rounded-xl pl-4 py-2 bg-gray-100'> <label htmlFor='article-search'> <Image src='/assets/icons/search.svg' alt='검색 아이콘' width={16} height={16} style={{ width: 16, height: 16 }} /> </label> - <input type='text' id='article-search' placeholder='검색할 상품을 입력해주세요' className='flex-1 bg-gray-100 outline-none' value={search} onChange={onChangeSearch} onKeyDown={onKeyDown} /> + <input + type='text' + id='article-search' + placeholder='검색할 상품을 입력해주세요' + className='flex-1 bg-gray-100 text-sm outline-none md:text-base' + value={search} + onChange={onChangeSearch} + onKeyDown={onKeyDown} + /> </div> ); } diff --git a/src/components/Auth/SigninForm/index.tsx b/src/components/Auth/SigninForm/index.tsx new file mode 100644 index 000000000..d39fd1888 --- /dev/null +++ b/src/components/Auth/SigninForm/index.tsx @@ -0,0 +1,35 @@ +'use client'; + +import { submitLogin } from '@/actions/submit-login'; +import { useRouter } from 'next/navigation'; +import { useActionState, useEffect } from 'react'; + +const SUBMIT_BTN_CLASSNAME = 'mt-4 rounded-full py-4 bg-gray-400 font-semibold text-xl text-white'; + +export default function SigninForm() { + const [state, action, isPending] = useActionState(submitLogin, null); + const router = useRouter(); + + useEffect(() => { + if (state && !state.status) alert(state.error); + if (state?.response && 'accessToken' in state?.response) { + localStorage.setItem('accessToken', state.response.accessToken); + localStorage.setItem('refreshToken', state.response.refreshToken); + router.replace('/'); + } + }, [state, router]); + + return ( + <form action={action} className='flex flex-col gap-4 w-[60%]'> + <label htmlFor='email' className='text-lg font-bold'> + 이메일 + </label> + <input type='email' name='email' id='email' placeholder='이메일을 입력해주세요' className='rounded-xl p-4 bg-gray-100 focus:outline-blue-100' required aria-required /> + <label htmlFor='password' className='text-lg font-bold'> + 비밀번호 + </label> + <input type='password' name='password' id='password' placeholder='비밀번호를 입력해주세요' className='rounded-xl p-4 bg-gray-100 focus:outline-blue-100' required aria-required /> + {isPending ? <div className={SUBMIT_BTN_CLASSNAME}>로그인 중입니다.</div> : <button className={SUBMIT_BTN_CLASSNAME}>로그인</button>} + </form> + ); +} diff --git a/src/components/BestArticleList/BestArticle/index.tsx b/src/components/BestArticleList/BestArticle/index.tsx index 8055264ad..7cb5945d4 100644 --- a/src/components/BestArticleList/BestArticle/index.tsx +++ b/src/components/BestArticleList/BestArticle/index.tsx @@ -1,15 +1,16 @@ import Image from 'next/image'; import { Article } from '@/types'; import dayjs from 'dayjs'; +import Link from 'next/link'; const DEFAULT_IMAGE = '/assets/icons/profile.svg'; -export default function BestArticle({ article, hidden }: { article: Article; hidden: string }) { +export default function BestArticle({ article, className }: { article: Article; className: string }) { const date = dayjs(article.createdAt).format('YYYY. MM. DD'); const validImageUrl = article.image ?? DEFAULT_IMAGE; return ( - <div className={`flex-1 flex flex-col gap-6 rounded-lg p-6 bg-gray-50 ${hidden}`}> + <Link href={`/board/${article.id}`} className={`flex-1 flex flex-col gap-6 rounded-lg p-6 bg-gray-50 ${className}`}> <Image src='/assets/icons/badge.svg' alt='Best 뱃지 이미지' width={102} height={30} style={{ width: 102, height: 30 }} /> <div className='flex gap-2 justify-between'> <p className='text-xl font-semibold'>{article.title}</p> @@ -27,6 +28,6 @@ export default function BestArticle({ article, hidden }: { article: Article; hid </div> <p className='text-gray-400'>{date}</p> </div> - </div> + </Link> ); } diff --git a/src/components/BestArticleList/index.tsx b/src/components/BestArticleList/index.tsx index b80423a4e..da980bbfd 100644 --- a/src/components/BestArticleList/index.tsx +++ b/src/components/BestArticleList/index.tsx @@ -19,11 +19,11 @@ export default async function BestArticleList() { return ( <div className='md:flex md:gap-6 w-[100%]'> {list.map((article, i) => { - let hidden = ''; - if (i === 1) hidden = 'hidden md:flex'; - else if (i === 2) hidden = 'hidden pc:flex'; + let className = ''; + if (i === 1) className = 'hidden md:flex'; + else if (i === 2) className = 'hidden pc:flex'; - return <BestArticle key={article.id} article={article} hidden={hidden} />; + return <BestArticle key={article.id} article={article} className={className} />; })} </div> ); diff --git a/src/components/ClientProvider/index.tsx b/src/components/ClientProvider/index.tsx new file mode 100644 index 000000000..e76dadae7 --- /dev/null +++ b/src/components/ClientProvider/index.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; +import { ReactNode, useState } from 'react'; + +export default function ClientProvider({ children }: { children: ReactNode }) { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: Infinity, + }, + }, + }), + ); + return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>; +} diff --git a/src/components/CommentForm/index.tsx b/src/components/CommentForm/index.tsx new file mode 100644 index 000000000..1bd6f40c5 --- /dev/null +++ b/src/components/CommentForm/index.tsx @@ -0,0 +1,72 @@ +'use client'; +import { submitComment } from '@/actions/submit-comment'; +import { useForm } from 'react-hook-form'; +import { useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { useRouter } from 'next/navigation'; + +interface CommentFormData { + content: string; +} + +export default function CommentForm({ id }: { id: string }) { + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + watch, + reset, + } = useForm<CommentFormData>({ + defaultValues: { content: '' }, + mode: 'onChange', + }); + const [resultMessage, setResultMessage] = useState<string | null>(null); + const router = useRouter(); + const formValues = watch(); + const isFormValid = formValues.content; + const queryClient = useQueryClient(); + + const onSubmit = async (data: CommentFormData) => { + try { + const formData = new FormData(); + formData.append('content', data.content); + const accessToken = localStorage.getItem('accessToken'); + const refreshToken = localStorage.getItem('refreshToken'); + const result = await submitComment(formData, accessToken, refreshToken, Number(id)); + setResultMessage(result.message); + reset({ content: '' }); + + if (result.accessToken) localStorage.setItem('accessToken', result.accessToken); + + if (result.success) { + queryClient.invalidateQueries({ + queryKey: ['article-comments', Number(id)], + }); + } + + if (result.message.includes('세션')) { + router.push(`/signin`); + } + } catch (err) { + setResultMessage(err instanceof Error ? err.message : '댓글 등록 중 오류가 발생했습니다.'); + } + }; + + return ( + <form onSubmit={handleSubmit(onSubmit)} className='flex flex-col gap-4 mt-6 mx-auto w-[100%] max-w-[1200px] px-6'> + <label className='flex flex-col gap-2'> + <span className='font-bold text-lg'>댓글달기</span> + <textarea + {...register('content', { required: '내용을 반드시 입력해야 합니다.' })} + className='w-full h-[104px] rounded-xl py-4 px-6 bg-gray-100 resize-none' + placeholder='내용을 입력해주세요' + /> + {errors.content && <span className='text-red-error'>{errors.content.message}</span>} + </label> + <button type='submit' className={`${isSubmitting || !isFormValid ? 'bg-gray-400' : 'bg-blue-100'} ml-auto w-[100px] text-white py-2 px-6 rounded-lg`} disabled={isSubmitting || !isFormValid}> + 등록 + </button> + {resultMessage && <p className='text-green-500'>{resultMessage}</p>} + </form> + ); +} diff --git a/src/types.d.ts b/src/types.d.ts deleted file mode 100644 index e589d460c..000000000 --- a/src/types.d.ts +++ /dev/null @@ -1,22 +0,0 @@ -export interface ArticlesParam { - page?: number; - pageSize?: number; - orderBy?: 'recent' | 'like'; - keyword?: string; -} - -interface Writer { - id: number; - nickname: string; -} - -export interface Article { - id: number; - title: string; - content: string; - image: string | null; - likeCount: number; - createdAt: string; - updatedAt: string; - writer: Writer; -} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 000000000..e42d30b9e --- /dev/null +++ b/src/types.ts @@ -0,0 +1,70 @@ +export interface ArticlesParam { + page?: number; + pageSize?: number; + orderBy?: 'recent' | 'like'; + keyword?: string; +} + +interface Writer { + id: number; + nickname: string; +} + +export interface Article { + id: number; + title: string; + content: string; + image: string | null; + likeCount: number; + createdAt: string; + updatedAt: string; + writer: Writer; +} + +export interface SigninSuccessResponse { + user: { + id: number; + nickname: string; + image: null; + createdAt: string; + updatedAt: string; + email: string; + }; + accessToken: string; + refreshToken: string; +} + +export interface SigninFailResponse { + message: string; + details: { + email?: { + message: string; + }; + password?: { + message: string; + }; + }; +} + +export interface ArticleFormData { + title: string; + content: string; + image: File | null; +} + +export interface DetailArticle extends Article { + isLiked: boolean; +} + +export interface Comment { + id: number; + content: string; + createdAt: string; + updatedAt: string; + writer: Writer & { image: null | string }; +} + +export interface Comments { + list: Comment[]; + nextCursor: null | string; +} diff --git a/src/utils/getDiffTime.ts b/src/utils/getDiffTime.ts new file mode 100644 index 000000000..acca57bc0 --- /dev/null +++ b/src/utils/getDiffTime.ts @@ -0,0 +1,38 @@ +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import customParseFormat from 'dayjs/plugin/customParseFormat'; + +dayjs.extend(relativeTime); +dayjs.extend(customParseFormat); + +export default function getDiffTime(dateStr: string) { + const now = dayjs(); + const date = dayjs(dateStr); + + const diffInSeconds = now.diff(date, 'second'); + const diffInMinutes = now.diff(date, 'minute'); + const diffInHours = now.diff(date, 'hour'); + const diffInDays = now.diff(date, 'day'); + const diffInMonths = now.diff(date, 'month'); + + if (diffInSeconds < 60) { + return `${diffInSeconds}초 전`; + } + if (diffInMinutes < 60) { + return `${diffInMinutes}분 전`; + } + if (diffInHours < 24) { + return `${diffInHours}시간 전`; + } + if (diffInDays < 7) { + return `${diffInDays}일 전`; + } + if (diffInDays < 30) { + const diffInWeeks = Math.floor(diffInDays / 7); + return `${diffInWeeks}주 전`; + } + if (diffInMonths < 12) { + return `${diffInMonths}개월 전`; + } + return date.format('YYYY.MM.DD HH:mm'); +} diff --git a/tailwind.config.ts b/tailwind.config.ts index f2083ac2a..dfc725ddf 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -17,6 +17,7 @@ export default { 50: '#F9FAFB', }, blue: { + 50: '#E6F2FF', 100: '#3692FF', 200: '#1967D6', 300: '#1251AA',