diff --git a/package-lock.json b/package-lock.json index 958ff80..bcea2d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "ISC", "dependencies": { + "@tanstack/react-virtual": "^3.13.12", "class-variance-authority": "^0.7.1", "gsap": "^3.13.0", "lottie-react": "^2.4.1", @@ -3297,6 +3298,33 @@ "tailwindcss": "4.1.13" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.12", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz", + "integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.12", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz", + "integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@trysound/sax": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", diff --git a/package.json b/package.json index 061e194..f98fce8 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ ] }, "dependencies": { + "@tanstack/react-virtual": "^3.13.12", "class-variance-authority": "^0.7.1", "gsap": "^3.13.0", "lottie-react": "^2.4.1", diff --git a/src/app/community/[id]/loading.tsx b/src/app/community/[id]/loading.tsx index 6fca4e1..f8db77a 100644 --- a/src/app/community/[id]/loading.tsx +++ b/src/app/community/[id]/loading.tsx @@ -1,30 +1,7 @@ -function Loading() { - return ( -
- {/* 메인 콘텐츠 */} -
- {/* DetailHeader 자리 */} -
- - {/* Title 자리 */} -
-
+import DetailSkeleton from '@/domains/community/detail/DetailSkeleton'; - {/* Content 자리 */} -
-
-
- - {/* 댓글 */} -
-
- {[...Array(2)].map((_, i) => ( -
- ))} -
-
-
- ); +function Loading() { + return ; } export default Loading; diff --git a/src/app/community/[id]/page.tsx b/src/app/community/[id]/page.tsx index 6d22101..c80776f 100644 --- a/src/app/community/[id]/page.tsx +++ b/src/app/community/[id]/page.tsx @@ -10,23 +10,28 @@ import Comment from '@/domains/community/detail/Comment'; import StarBg from '@/domains/shared/components/star-bg/StarBg'; import { useParams } from 'next/navigation'; import { useEffect, useState } from 'react'; +import DetailSkeleton from '@/domains/community/detail/DetailSkeleton'; function Page() { const params = useParams(); const [postDetail, setPostDetail] = useState(null); + const [isLoading, setIsLoading] = useState(false); useEffect(() => { const postId = params.id; const fetchData = async () => { + setIsLoading(true); const data = await fetchPostById(postId); if (!data) return; setPostDetail(data); + setIsLoading(false); }; fetchData(); }, [params.id, setPostDetail]); - if (!postDetail) return
게시글을 불러오지 못했습니다.
; + if (isLoading) return ; + if (!postDetail) return null; const { categoryName, diff --git a/src/domains/community/api/fetchComment.ts b/src/domains/community/api/fetchComment.ts index 492d0a6..2eb8302 100644 --- a/src/domains/community/api/fetchComment.ts +++ b/src/domains/community/api/fetchComment.ts @@ -5,11 +5,84 @@ export const fetchComment = async (postId: number): Promise comment.status !== 'DELETED' + ); + + return filteredComments; } catch (err) { console.error('해당 글의 댓글 조회 실패', err); return null; } }; + +export const postComments = async (postId: number, content: string) => { + try { + const res = await fetch(`${getApi}/posts/${postId}/comments`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ content }), + }); + + const text = await res.text(); + + if (!res.ok) { + console.error(`댓글 작성 실패: ${res.status}`, text); + return null; + } + const data = JSON.parse(text); + return data; + } catch (err) { + console.error('해당 글의 댓글 작성 실패', err); + return null; + } +}; + +export async function updateComment( + accessToken: string | null, + postId: number, + commentId: number, + content: string +): Promise { + const response = await fetch(`${getApi}/posts/${postId}/comments/${commentId}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ content }), + }); + + if (!response.ok) { + const errorText = await response.text(); // 👈 응답 본문을 텍스트로 읽기 + console.error('서버 응답 에러:', errorText); + throw new Error(`댓글 수정 실패: ${response.status}`); + } +} + +export async function deleteComment( + accessToken: string | null, + postId: number, + commentId: number +): Promise { + const response = await fetch(`${getApi}/posts/${postId}/comments/${commentId}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + const errorText = await response.text(); // 👈 응답 본문을 텍스트로 읽기 + console.error('서버 응답 에러:', errorText); + throw new Error(`댓글 수정 실패: ${response.status}`); + } +} diff --git a/src/domains/community/api/fetchPost.ts b/src/domains/community/api/fetchPost.ts index 74e9586..29fdb67 100644 --- a/src/domains/community/api/fetchPost.ts +++ b/src/domains/community/api/fetchPost.ts @@ -4,7 +4,7 @@ import { ParamValue } from 'next/dist/server/request/params'; export const fetchPost = async (): Promise => { try { - const res = await fetch(`${getApi}/posts`, { + const res = await fetch(`${getApi}/posts?postSortStatus=LATEST`, { method: 'GET', cache: 'no-store', }); diff --git a/src/domains/community/api/fetchView.ts b/src/domains/community/api/fetchView.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/domains/community/components/post-info/PostInfo.tsx b/src/domains/community/components/post-info/PostInfo.tsx index 8ef3b62..d30403b 100644 --- a/src/domains/community/components/post-info/PostInfo.tsx +++ b/src/domains/community/components/post-info/PostInfo.tsx @@ -28,9 +28,9 @@ function PostInfo({ )}
  • {elapsedTime(createdAt)}
  • -
  • 조회 {viewCount}
  • +
  • 조회 {viewCount || 0}
  • -
  • 댓글 {commentCount}
  • +
  • 댓글 {commentCount + 1 || 0}
  • ); } diff --git a/src/domains/community/components/textarea/AutoGrowingTextarea.tsx b/src/domains/community/components/textarea/AutoGrowingTextarea.tsx new file mode 100644 index 0000000..8f12a29 --- /dev/null +++ b/src/domains/community/components/textarea/AutoGrowingTextarea.tsx @@ -0,0 +1,64 @@ +import { Virtualizer } from '@tanstack/react-virtual'; +import gsap from 'gsap'; +import { useEffect, useRef } from 'react'; + +type Props = { + value: string; + onChange: (e: React.ChangeEvent) => void; + rowVirtualize: Virtualizer; +}; + +function AutoGrowingTextarea({ value, onChange, rowVirtualize }: Props) { + const textareaRef = useRef(null); + + useEffect(() => { + const textarea = textareaRef.current; + if (!textarea) return; + + textarea.style.height = 'auto'; // 높이 초기화 + textarea.style.height = textarea.scrollHeight + 'px'; // 스크롤 높이만큼 늘리기 + + const handleInput = () => { + textarea.style.height = 'auto'; + textarea.style.height = textarea.scrollHeight + 'px'; + }; + + textarea.addEventListener('input', handleInput); + return () => { + textarea.removeEventListener('input', handleInput); + }; + }, []); + + useEffect(() => { + if (textareaRef.current) { + requestAnimationFrame(() => { + const li = textareaRef.current?.closest('li') as HTMLElement | null; + if (li) { + rowVirtualize.measureElement(li); + } + }); + } + }, [value]); + + useEffect(() => { + if (!textareaRef.current) return; + gsap.fromTo( + textareaRef.current, + { autoAlpha: 0, y: -15 }, + { duration: 0.4, autoAlpha: 1, y: 0, ease: 'power2.out' } + ); + }, []); + + return ( +