From 864a5f663603543348377f0c76a85e4b54930555 Mon Sep 17 00:00:00 2001 From: jeongsarang Date: Sat, 26 Jul 2025 22:14:37 +0900 Subject: [PATCH 1/5] =?UTF-8?q?Feature:=20=EC=BB=A4=EB=AE=A4=EB=8B=88?= =?UTF-8?q?=ED=8B=B0=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 106 ++++++++++++++++++ package.json | 3 + src/App.tsx | 9 +- src/components/ui/avatar.tsx | 51 +++++++++ src/components/ui/separator.tsx | 28 +++++ src/hooks/usePost.ts | 64 +++++++++++ src/main.tsx | 28 +++-- src/pages/community/PostDetailPage.tsx | 69 ++++++++++++ .../community/__components/CommentForm.tsx | 39 +++++++ .../community/__components/CommentItem.tsx | 42 +++++++ .../community/__components/CommentSection.tsx | 31 +++++ .../community/__components/PostContent.tsx | 55 +++++++++ .../community/__components/PostHeader.tsx | 28 +++++ src/types/community.ts | 25 +++++ 14 files changed, 569 insertions(+), 9 deletions(-) create mode 100644 src/components/ui/avatar.tsx create mode 100644 src/components/ui/separator.tsx create mode 100644 src/hooks/usePost.ts create mode 100644 src/pages/community/PostDetailPage.tsx create mode 100644 src/pages/community/__components/CommentForm.tsx create mode 100644 src/pages/community/__components/CommentItem.tsx create mode 100644 src/pages/community/__components/CommentSection.tsx create mode 100644 src/pages/community/__components/PostContent.tsx create mode 100644 src/pages/community/__components/PostHeader.tsx create mode 100644 src/types/community.ts diff --git a/package-lock.json b/package-lock.json index d87de2c..3b61683 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,16 @@ "version": "0.0.0", "dependencies": { "@radix-ui/react-accordion": "^1.2.11", + "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.14", "@radix-ui/react-select": "^2.2.5", + "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-toggle": "^1.1.9", "@radix-ui/react-toggle-group": "^1.1.10", + "@tanstack/react-query": "^5.83.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -913,6 +916,33 @@ } } }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz", + "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collapsible": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.11.tgz", @@ -1372,6 +1402,29 @@ } } }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", @@ -1514,6 +1567,24 @@ } } }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-layout-effect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", @@ -2399,6 +2470,32 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@tanstack/query-core": { + "version": "5.83.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.83.0.tgz", + "integrity": "sha512-0M8dA+amXUkyz5cVUm/B+zSk3xkQAcuXuz5/Q/LveT4ots2rBpPTZOzd7yJa2Utsf8D2Upl5KyjhHRY+9lB/XA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.83.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.83.0.tgz", + "integrity": "sha512-/XGYhZ3foc5H0VM2jLSD/NyBRIOK4q9kfeml4+0x2DlL6xVuAcVEW+hTlTapAmejObg0i3eNqhkr2dT+eciwoQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.83.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -4688,6 +4785,15 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.4.tgz", diff --git a/package.json b/package.json index 4c4328c..c011300 100644 --- a/package.json +++ b/package.json @@ -11,13 +11,16 @@ }, "dependencies": { "@radix-ui/react-accordion": "^1.2.11", + "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.14", "@radix-ui/react-select": "^2.2.5", + "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-toggle": "^1.1.9", "@radix-ui/react-toggle-group": "^1.1.10", + "@tanstack/react-query": "^5.83.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/src/App.tsx b/src/App.tsx index 72022ea..18ac6ae 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,19 +1,26 @@ import GlobalLayout from "./components/layout/GlobalLayout"; import { OnboardingPage } from "./pages/onboarding/OnboardingPage"; +import { PostDetailPage } from './pages/community/PostDetailPage'; function App() { const path = window.location.pathname; let content; + if (path === '/onboarding') { content = ; + } else if (path.startsWith('/community/')) { + content = ; } else { content = ( <>

brand-primary 색상 테스트

brand 색상 테스트


- 온보딩 페이지 테스트 +
+ 온보딩 페이지 테스트 + 게시글 상세 페이지 테스트 +
); } diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx new file mode 100644 index 0000000..b7224f0 --- /dev/null +++ b/src/components/ui/avatar.tsx @@ -0,0 +1,51 @@ +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx new file mode 100644 index 0000000..275381c --- /dev/null +++ b/src/components/ui/separator.tsx @@ -0,0 +1,28 @@ +"use client" + +import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" + +import { cn } from "@/lib/utils" + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Separator } diff --git a/src/hooks/usePost.ts b/src/hooks/usePost.ts new file mode 100644 index 0000000..a6a9489 --- /dev/null +++ b/src/hooks/usePost.ts @@ -0,0 +1,64 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import type { Post, Comment } from '@/types/community'; + +const fetchPost = async (postId: string): Promise => { + const res = await fetch(`/api/posts/${postId}`); + if (!res.ok) throw new Error('게시글을 불러오는데 실패했습니다.'); + return res.json(); +}; + +const fetchComments = async (postId: string): Promise => { + const res = await fetch(`/api/posts/${postId}/comments`); + if (!res.ok) throw new Error('댓글을 불러오는데 실패했습니다.'); + return res.json(); +}; + +export function usePost(postId: string) { + return useQuery({ + queryKey: ['post', postId], + queryFn: async () => { + const [postData, commentsData] = await Promise.all([ + fetchPost(postId), + fetchComments(postId), + ]); + return { ...postData, comments: commentsData }; + }, + enabled: !!postId, + }); +} + +const togglePostLike = async (postId: string) => { + const res = await fetch(`/api/posts/${postId}/like`, { method: 'PUT' }); + if (!res.ok) throw new Error('좋아요 클릭에 실패했습니다.'); + return res.json(); +}; + +export function useTogglePostLike() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: togglePostLike, + onSuccess: (_data, postId) => { + queryClient.invalidateQueries({ queryKey: ['post', postId] }); + }, + }); +} + +const addComment = async ({ postId, content }: { postId: string, content: string }) => { + const res = await fetch(`/api/posts/${postId}/comments`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content }), + }); + if (!res.ok) throw new Error('댓글 작성에 실패했습니다.'); + return res.json(); +}; + +export function useAddComment() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: addComment, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ['post', variables.postId] }); + }, + }); +} \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx index bef5202..b644865 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,22 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import './index.css' +// src/main.tsx + +import React from 'react' +import ReactDOM from 'react-dom/client' import App from './App.tsx' +import './index.css' +import { BrowserRouter } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +// 1. QueryClient 인스턴스를 생성합니다. +const queryClient = new QueryClient(); -createRoot(document.getElementById('root')!).render( - - - , -) +ReactDOM.createRoot(document.getElementById('root')!).render( + + {/* 2. QueryClientProvider로 앱 전체를 감싸줍니다. */} + + + + + + , +) \ No newline at end of file diff --git a/src/pages/community/PostDetailPage.tsx b/src/pages/community/PostDetailPage.tsx new file mode 100644 index 0000000..8d22a05 --- /dev/null +++ b/src/pages/community/PostDetailPage.tsx @@ -0,0 +1,69 @@ +import { useParams } from "react-router-dom"; +import { PostHeader } from "./__components/PostHeader"; +import { PostContent } from "./__components/PostContent"; +import { CommentSection } from "./__components/CommentSection"; +import { CommentForm } from "./__components/CommentForm"; +import GlobalLayout from "@/components/layout/GlobalLayout"; +import { usePost, useTogglePostLike, useAddComment } from "@/hooks/usePost"; + +export function PostDetailPage() { + const { postId } = useParams<{ postId: string }>(); + const { data: post, isLoading, isError, error } = usePost(postId!); + const { mutate: toggleLike, isPending: isLikePending } = useTogglePostLike(); + const { mutate: addNewComment, isPending: isAddingComment } = useAddComment(); + + if (isLoading) { + return ( + +
로딩 중...
+
+ ); + } + + if (isError) { + return ( + +
에러 발생: {error.message}
+
+ ); + } + if (!post) { + return ( + +
게시글이 없습니다.
+
+ ); + } + + const handleToggleCommentLike = (commentId: number) => + console.log("like comment:", commentId); + const handleBookmarkClick = () => console.log("bookmark post"); + + return ( + +
+ +
+ toggleLike(postId!)} + isLikePending={isLikePending} + /> + + addNewComment({ postId: postId!, content }) + } + onToggleCommentLike={handleToggleCommentLike} + /> +
+ + addNewComment({ postId: postId!, content }) + } + isPending={isAddingComment} + /> +
+
+ ); +} diff --git a/src/pages/community/__components/CommentForm.tsx b/src/pages/community/__components/CommentForm.tsx new file mode 100644 index 0000000..2a81a5e --- /dev/null +++ b/src/pages/community/__components/CommentForm.tsx @@ -0,0 +1,39 @@ +import { useState } from "react"; +import { Textarea } from "@/components/ui/textarea"; +import { Button } from "@/components/ui/button"; + +interface CommentFormProps { + onSubmit: (content: string) => void; + isPending: boolean; +} + +export function CommentForm({ onSubmit, isPending }: CommentFormProps) { + const [comment, setComment] = useState(""); + + const handleSubmit = () => { + if (!comment.trim()) return; + onSubmit(comment); + setComment(""); + }; + + return ( +
+