From 48af498e63b751083579ac5a1dd91eb3ea46ff13 Mon Sep 17 00:00:00 2001 From: Monixc Date: Sat, 9 Aug 2025 23:36:00 +0900 Subject: [PATCH 01/26] =?UTF-8?q?feat:=20Comment=20=EC=9D=B8=ED=84=B0?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/types/community.types.ts | 22 ++++------------------ 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/src/types/community.types.ts b/src/types/community.types.ts index 485700d..2638472 100644 --- a/src/types/community.types.ts +++ b/src/types/community.types.ts @@ -1,27 +1,13 @@ -export interface Reply { - id: number; - author: { - name: string; - avatarUrl: string; - }; - content: string; - createdAt: string; - likeCount: number; - isLiked: boolean; -} - export interface Comment { id: number; + postId: number; author: { - name: string; - avatarUrl: string; + nickname: string; + avatar: string; }; content: string; + editable: boolean; createdAt: string; - likeCount: number; - isLiked: boolean; - replyCount: number; - replies?: Reply[]; } export interface Post { From cd02b914d02b7db3804aa0f24b843f3823bbbf8c Mon Sep 17 00:00:00 2001 From: Monixc Date: Sun, 10 Aug 2025 05:16:15 +0900 Subject: [PATCH 02/26] =?UTF-8?q?feat:=20NearbyUser=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80,=20Post=20=EC=9D=B8=ED=84=B0?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=8A=A4=EC=97=90=20content=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/types/developer.types.ts | 39 ++++++++++++++++++++++++++++++++++++ src/types/post.types.ts | 1 + 2 files changed, 40 insertions(+) diff --git a/src/types/developer.types.ts b/src/types/developer.types.ts index 8b2cdc8..ee4fe0d 100644 --- a/src/types/developer.types.ts +++ b/src/types/developer.types.ts @@ -8,3 +8,42 @@ export interface Developer { image: string; category: "nearby" | "popular" | "favorite"; } + +export interface NearbyUser { + location: { + lng: number; + lat: number; + }; + distance: number; + nickname: string; + avatar: string; + age: number; + experience: number; + position: { + id: number; + field: string; + role: string; + }; + techStack: Array<{ + id: number; + value: string; + }>; + interests: Array<{ + id: number; + value: string; + }>; + nSubscribers: number; +} + +export interface NearbyUsersResponse { + results: NearbyUser[]; +} + +export interface NearbyUsersParams { + radius: number; + age?: string; + experience?: string; + positionIds?: number[]; + techIds?: number[]; + interestIds?: number[]; +} diff --git a/src/types/post.types.ts b/src/types/post.types.ts index efad29d..5b2f11d 100644 --- a/src/types/post.types.ts +++ b/src/types/post.types.ts @@ -10,6 +10,7 @@ export interface Post { nickname: string; }; title: string; + content: string; nComments: number; nLikes: number; createdAt: string; From a1cf54be3247ecc352d1e6a35af26dd8e2b3f18b Mon Sep 17 00:00:00 2001 From: Monixc Date: Sun, 10 Aug 2025 05:22:37 +0900 Subject: [PATCH 03/26] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20=EB=8C=93?= =?UTF-8?q?=EA=B8=80=20=EA=B4=80=EB=A0=A8=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 게시글 상세 조회 기능(fetchPostDetail) 추가 - 댓글 조회(fetchComments) 및 댓글 생성(createComment) 기능 추가 - 게시글 검색(searchPosts) 기능 추가 --- src/lib/api/posts.ts | 73 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/lib/api/posts.ts b/src/lib/api/posts.ts index c772302..5685f46 100644 --- a/src/lib/api/posts.ts +++ b/src/lib/api/posts.ts @@ -1,4 +1,5 @@ import type { Post } from "@/types/post.types"; +import type { Comment } from "@/types/community.types"; import type { UICategory } from "@/constants/board"; import { createApiUrl, createAuthHeaders } from "./config"; @@ -7,6 +8,7 @@ export interface Board { value: string; } +//게시판 카테고리 조회 export const fetchBoards = async (): Promise => { const response = await fetch(`${createApiUrl("/boards")}`, { headers: createAuthHeaders(), @@ -20,6 +22,7 @@ export const fetchBoards = async (): Promise => { return data.results || []; }; +//게시글 목록 조회회 export const fetchPosts = async (category: UICategory): Promise => { const url = category === "all" @@ -38,6 +41,7 @@ export const fetchPosts = async (category: UICategory): Promise => { return data.results || []; }; +//게시글 생성성 export const createPost = async (data: { title: string; content: string; @@ -60,3 +64,72 @@ export const createPost = async (data: { throw new Error("게시글 작성에 실패했습니다."); } }; + +//게시글 상세 조회 +export const fetchPostDetail = async (postId: string): Promise => { + const response = await fetch(`${createApiUrl(`/posts/details/${postId}`)}`, { + headers: createAuthHeaders(), + }); + + if (!response.ok) { + throw new Error("게시글을 불러오는 데 실패했습니다."); + } + + const data = await response.json(); + console.log("🔍 fetchPostDetail API Response:", data); + + // API는 게시글 데이터를 직접 반환 + return data; +}; + +// 댓글 조회 +export const fetchComments = async (postId: string): Promise => { + const response = await fetch(`${createApiUrl(`/posts/${postId}`)}`, { + headers: createAuthHeaders(), + }); + + if (!response.ok) { + throw new Error("댓글 목록을 불러오는 데 실패했습니다."); + } + + const data = await response.json(); + return data.results || []; +}; + +// 댓글 생성 +export const createComment = async ( + postId: string, + content: string +): Promise<{ nComments: number }> => { + const response = await fetch(`${createApiUrl(`/posts/${postId}`)}`, { + method: "POST", + headers: createAuthHeaders(), + body: JSON.stringify({ + content, + }), + }); + + if (!response.ok) { + throw new Error("댓글 작성에 실패했습니다."); + } + + const data = await response.json(); + return data; +}; + +// 게시글 검색 +export const searchPosts = async (query: string): Promise => { + const response = await fetch( + `${createApiUrl(`/posts/search?query=${encodeURIComponent(query)}`)}`, + { + headers: createAuthHeaders(), + } + ); + + if (!response.ok) { + throw new Error("게시글 검색에 실패했습니다."); + } + + const data = await response.json(); + return data.results || []; +}; From 36d0e3fe5251f8a6809c78a7fca488d944e0d766 Mon Sep 17 00:00:00 2001 From: Monixc Date: Sun, 10 Aug 2025 05:31:11 +0900 Subject: [PATCH 04/26] =?UTF-8?q?feat:=20=EB=8C=93=EA=B8=80=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20API=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 댓글 생성(createComment) 기능 삭제 - 관련된 코드 정리 --- src/lib/api/posts.ts | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/src/lib/api/posts.ts b/src/lib/api/posts.ts index 5685f46..996e002 100644 --- a/src/lib/api/posts.ts +++ b/src/lib/api/posts.ts @@ -96,27 +96,6 @@ export const fetchComments = async (postId: string): Promise => { return data.results || []; }; -// 댓글 생성 -export const createComment = async ( - postId: string, - content: string -): Promise<{ nComments: number }> => { - const response = await fetch(`${createApiUrl(`/posts/${postId}`)}`, { - method: "POST", - headers: createAuthHeaders(), - body: JSON.stringify({ - content, - }), - }); - - if (!response.ok) { - throw new Error("댓글 작성에 실패했습니다."); - } - - const data = await response.json(); - return data; -}; - // 게시글 검색 export const searchPosts = async (query: string): Promise => { const response = await fetch( From cc881f517a965eafac0086706e60fb9870f61c6d Mon Sep 17 00:00:00 2001 From: Monixc Date: Sun, 10 Aug 2025 05:36:45 +0900 Subject: [PATCH 05/26] =?UTF-8?q?feat:=20usePost=20=ED=9B=85=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20API=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - usePost 훅 삭제 - 게시글 상세 조회 및 댓글 조회 기능을 usePosts 훅으로 통합 - 관련된 API 호출(fetchPostDetail, fetchComments) 추가 및 정리 --- src/hooks/queries/usePosts.ts | 52 +++++++++++++++++-- src/hooks/usePost.ts | 96 ----------------------------------- src/lib/api/posts.ts | 3 -- 3 files changed, 49 insertions(+), 102 deletions(-) delete mode 100644 src/hooks/usePost.ts diff --git a/src/hooks/queries/usePosts.ts b/src/hooks/queries/usePosts.ts index c309640..d97b29d 100644 --- a/src/hooks/queries/usePosts.ts +++ b/src/hooks/queries/usePosts.ts @@ -1,13 +1,22 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { fetchPosts, createPost, fetchBoards } from "@/lib/api/posts"; +import { + fetchPosts, + createPost, + fetchBoards, + fetchPostDetail, + searchPosts, + fetchComments, +} from "@/lib/api/posts"; import type { UICategory } from "@/constants/board"; +import type { Post } from "@/types/post.types"; +import type { Comment } from "@/types/community.types"; export const useBoards = () => { return useQuery({ queryKey: ["boards"], queryFn: fetchBoards, - staleTime: 10 * 60 * 1000, // 10분 - gcTime: 30 * 60 * 1000, // 30분 + staleTime: 10 * 60 * 1000, + gcTime: 30 * 60 * 1000, }); }; @@ -20,6 +29,33 @@ export const usePosts = (category: UICategory) => { }); }; +export const usePostDetail = (postId: string) => { + return useQuery({ + queryKey: ["post", postId], + queryFn: async () => { + const result = await fetchPostDetail(postId); + if (!result) { + throw new Error("게시글 데이터가 없습니다."); + } + return result; + }, + enabled: !!postId, + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + retry: 1, + }); +}; + +export const useSearchPosts = (query: string) => { + return useQuery({ + queryKey: ["search", query], + queryFn: () => searchPosts(query), + enabled: !!query && query.trim().length > 0, + staleTime: 2 * 60 * 1000, + gcTime: 5 * 60 * 1000, + }); +}; + export const useCreatePost = () => { const queryClient = useQueryClient(); @@ -30,3 +66,13 @@ export const useCreatePost = () => { }, }); }; + +export const useComments = (postId: string) => { + return useQuery({ + queryKey: ["comments", postId], + queryFn: () => fetchComments(postId), + enabled: !!postId, + staleTime: 2 * 60 * 1000, + gcTime: 5 * 60 * 1000, + }); +}; diff --git a/src/hooks/usePost.ts b/src/hooks/usePost.ts deleted file mode 100644 index 6b428e4..0000000 --- a/src/hooks/usePost.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import type { Post, Comment } from "@/types/community.types"; - -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: () => fetchPost(postId), - 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] }); - }, - }); -} - -const toggleCommentLike = async (commentId: number) => { - const res = await fetch(`/api/comments/${commentId}/like`, { method: "PUT" }); - if (!res.ok) throw new Error("댓글 좋아요 클릭에 실패했습니다."); - return res.json(); -}; - -export function useToggleCommentLike() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: toggleCommentLike, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["post"] }); - }, - }); -} - -const toggleReplyLike = async (replyId: number) => { - const res = await fetch(`/api/replies/${replyId}/like`, { method: "PUT" }); - if (!res.ok) throw new Error("답글 좋아요 클릭에 실패했습니다."); - return res.json(); -}; - -export function useToggleReplyLike() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: toggleReplyLike, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["post"] }); - }, - }); -} diff --git a/src/lib/api/posts.ts b/src/lib/api/posts.ts index 996e002..cdb774d 100644 --- a/src/lib/api/posts.ts +++ b/src/lib/api/posts.ts @@ -76,9 +76,6 @@ export const fetchPostDetail = async (postId: string): Promise => { } const data = await response.json(); - console.log("🔍 fetchPostDetail API Response:", data); - - // API는 게시글 데이터를 직접 반환 return data; }; From 894b1927bb461445e8ddd38b898256525001766d Mon Sep 17 00:00:00 2001 From: Monixc Date: Sun, 10 Aug 2025 05:50:23 +0900 Subject: [PATCH 06/26] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=B0=8F=20?= =?UTF-8?q?=EB=82=A0=EC=A7=9C=20=ED=98=95=EC=8B=9D=ED=99=94=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PostDetailPage 컴포넌트를 새로 생성하고, 게시글 상세 조회 기능 구현 - 날짜 형식화 관련 함수(formatRelativeTime, formatAbsoluteTime 등) 추가 - 기존 PostDetailPage.tsx 파일 삭제 및 관련 코드 정리 - 댓글 및 게시글 목록에서 날짜 형식화 적용 --- src/lib/date.ts | 50 ++++++++ src/main.tsx | 2 +- src/pages/community/PostDetailPage.tsx | 86 ------------- .../community/__components/CommentItem.tsx | 66 ++-------- src/pages/community/index.tsx | 119 ++++++++++++++++++ src/pages/home/_components/ListItem.tsx | 11 +- 6 files changed, 183 insertions(+), 151 deletions(-) create mode 100644 src/lib/date.ts delete mode 100644 src/pages/community/PostDetailPage.tsx create mode 100644 src/pages/community/index.tsx diff --git a/src/lib/date.ts b/src/lib/date.ts new file mode 100644 index 0000000..cb844ce --- /dev/null +++ b/src/lib/date.ts @@ -0,0 +1,50 @@ +export const formatRelativeTime = (dateString: string): string => { + const now = new Date(); + const date = new Date(dateString); + const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); + + if (diffInSeconds < 60) { + return `${diffInSeconds}초 전`; + } + + const diffInMinutes = Math.floor(diffInSeconds / 60); + if (diffInMinutes < 60) { + return `${diffInMinutes}분 전`; + } + + const diffInHours = Math.floor(diffInMinutes / 60); + if (diffInHours < 24) { + return `${diffInHours}시간 전`; + } + + return formatAbsoluteTime(dateString); +}; + +export const formatAbsoluteTime = (dateString: string): string => { + const date = new Date(dateString); + return date.toLocaleDateString("ko-KR", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +}; + +export const formatPostDetailDate = (dateString: string): string => { + const date = new Date(dateString); + return date.toLocaleDateString("ko-KR", { + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +}; + +export const formatCommentDate = (dateString: string): string => { + return formatRelativeTime(dateString); +}; + +export const formatPostListDate = (dateString: string): string => { + return formatRelativeTime(dateString); +}; diff --git a/src/main.tsx b/src/main.tsx index d13da4a..1b43f71 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -10,7 +10,7 @@ import { MyPage } from "./pages/my/index.tsx"; import { DeveloperPage } from "./pages/developer/index.tsx"; import { ExplorePage } from "./pages/explore/index.tsx"; import { OnboardingPage } from "./pages/onboarding/index.tsx"; -import { PostDetailPage } from "./pages/community/PostDetailPage.tsx"; +import { PostDetailPage } from "./pages/community/index.tsx"; import AuthCallbackPage from "./pages/auth/AuthCallbackPage.tsx"; import ChatPage from "./pages/chat/index.tsx"; import SearchPage from "./pages/search/index.tsx"; diff --git a/src/pages/community/PostDetailPage.tsx b/src/pages/community/PostDetailPage.tsx deleted file mode 100644 index d940d59..0000000 --- a/src/pages/community/PostDetailPage.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { useParams, useNavigate } from "react-router-dom"; -import { PostContent } from "./__components/PostContent"; -import { CommentSection } from "./__components/CommentSection"; -import { CommentForm } from "./__components/CommentForm"; -import Header from "@/components/layout/Header"; -import { ArrowLeft, Bookmark } from "lucide-react"; -import { - usePost, - useTogglePostLike, - useAddComment, - useToggleCommentLike, - useToggleReplyLike, -} from "@/hooks/usePost"; - -export function PostDetailPage() { - const { postId } = useParams<{ postId: string }>(); - const navigate = useNavigate(); - - if (!postId) { - return
유효하지 않은 게시글 ID입니다.
; - } - - const { data: post, isLoading, isError, error } = usePost(postId); - const { mutate: toggleLike, isPending: isLikePending } = useTogglePostLike(); - const { mutate: addNewComment, isPending: isAddingComment } = useAddComment(); - const { mutate: toggleCommentLike } = useToggleCommentLike(); - const { mutate: toggleReplyLike } = useToggleReplyLike(); - - if (isLoading) { - return
로딩 중...
; - } - if (isError) { - return
에러 발생: {error.message}
; - } - if (!post) { - return
게시글이 없습니다.
; - } - - const handleToggleCommentLike = (commentId: number) => { - toggleCommentLike(commentId); - }; - - const handleToggleReplyLike = (replyId: number) => { - toggleReplyLike(replyId); - }; - - const handleBackClick = () => { - navigate(-1); - }; - - const handleBookmarkClick = () => { - console.log("북마크 클릭"); - }; - - return ( -
-
} - rightIcon={} - onLeftClick={handleBackClick} - onRightClick={handleBookmarkClick} - /> -
-
-
- toggleLike(postId)} - isLikePending={isLikePending} - /> - -
- addNewComment({ postId, content })} - isPending={isAddingComment} - /> -
-
-
- ); -} diff --git a/src/pages/community/__components/CommentItem.tsx b/src/pages/community/__components/CommentItem.tsx index 238a431..636d104 100644 --- a/src/pages/community/__components/CommentItem.tsx +++ b/src/pages/community/__components/CommentItem.tsx @@ -1,68 +1,24 @@ import type { Comment } from "@/types/community.types"; -import { Button } from "@/components/ui/button"; -import { Heart, MessageCircle } from "lucide-react"; -import { ReplyItem } from "./ReplyItem"; +import { formatCommentDate } from "@/lib/date"; interface CommentItemProps { comment: Comment; - onToggleLike: (commentId: number) => void; - onToggleReplyLike: (replyId: number) => void; } -export function CommentItem({ - comment, - onToggleLike, - onToggleReplyLike, -}: CommentItemProps) { +export function CommentItem({ comment }: CommentItemProps) { return ( -
-
-

- {comment.content} -

+
+

+ {comment.content} +

-
-
- {comment.author.name} - - {comment.createdAt} -
- -
- - -
+
+
+ {comment.author.nickname} + + {formatCommentDate(comment.createdAt)}
- - {comment.replies && comment.replies.length > 0 && ( -
- {comment.replies.map((reply) => ( - - ))} -
- )}
); } diff --git a/src/pages/community/index.tsx b/src/pages/community/index.tsx new file mode 100644 index 0000000..e43a2b1 --- /dev/null +++ b/src/pages/community/index.tsx @@ -0,0 +1,119 @@ +import { useParams, useNavigate } from "react-router-dom"; +import Header from "@/components/layout/Header"; +import { ArrowLeft, Bookmark, Heart, MessageCircle } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { usePostDetail, useComments } from "@/hooks/queries/usePosts"; +import { CommentForm } from "./__components/CommentForm"; +import { CommentSection } from "./__components/CommentSection"; +import { formatPostDetailDate } from "@/lib/date"; + +export function PostDetailPage() { + const { postId } = useParams<{ postId: string }>(); + const navigate = useNavigate(); + + if (!postId) { + return
유효하지 않은 게시글 ID입니다.
; + } + + const { data: post, isLoading, isError, error } = usePostDetail(postId); + const { + data: comments = [], + isLoading: isCommentsLoading, + isError: isCommentsError, + } = useComments(postId); + + if (isLoading) { + return
로딩 중...
; + } + if (isError) { + return
에러 발생: {error?.message}
; + } + if (!post) { + return
게시글이 없습니다.
; + } + + const handleBackClick = () => { + navigate(-1); + }; + + const handleBookmarkClick = () => { + console.log("북마크 클릭"); + }; + + return ( +
+
} + rightIcon={} + onLeftClick={handleBackClick} + onRightClick={handleBookmarkClick} + /> +
+
+
+ + {post.board.value} + +

{post.title}

+
+ {post.author.nickname} + + {formatPostDetailDate(post.createdAt)} +
+
+ + + +
+
+ {post.content ? ( + <>{post.content} + ) : ( +

+ 게시글 내용은 현재 API에서 제공되지 않습니다. +

+ )} +
+ +
+
+ + {post.nLikes} +
+
+ + {post.nComments} +
+
+
+ + + {isCommentsLoading ? ( +
+ 댓글을 불러오는 중... +
+ ) : isCommentsError ? ( +
+ 댓글을 불러오는 데 실패했습니다. +
+ ) : ( + {}} + onToggleReplyLike={() => {}} + /> + )} +
+
+
+ +
+ {}} isPending={false} /> +
+
+ ); +} diff --git a/src/pages/home/_components/ListItem.tsx b/src/pages/home/_components/ListItem.tsx index 14924e2..02a54d5 100644 --- a/src/pages/home/_components/ListItem.tsx +++ b/src/pages/home/_components/ListItem.tsx @@ -3,6 +3,7 @@ import { Badge } from "@/components/ui/badge"; import { Heart, MessageCircle } from "lucide-react"; import { useNavigate } from "react-router-dom"; import type { Post } from "@/types/post.types"; +import { formatPostListDate } from "@/lib/date"; interface ListItemProps { post: Post; @@ -15,14 +16,6 @@ export const ListItem = ({ post }: ListItemProps) => { navigate(`/community/${post.id}`); }; - const formatDate = (dateString: string) => { - const date = new Date(dateString); - return date.toLocaleDateString("ko-KR", { - month: "short", - day: "numeric", - }); - }; - return ( { - {post.author.nickname} · {formatDate(post.createdAt)} + {post.author.nickname} · {formatPostListDate(post.createdAt)}
From 27f9854b27da1df439186df9a6fb24871d7c510c Mon Sep 17 00:00:00 2001 From: Monixc Date: Sun, 10 Aug 2025 05:51:18 +0900 Subject: [PATCH 07/26] =?UTF-8?q?feat:=20useDebounce=20=ED=9B=85=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useDebounce 훅을 새로 생성하여 주어진 값의 디바운스 처리 기능 구현 - 지연 시간(delay) 설정에 따라 값이 변경될 때마다 디바운스된 값을 반환 --- src/hooks/useDebounce.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 src/hooks/useDebounce.ts diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts new file mode 100644 index 0000000..fd87c63 --- /dev/null +++ b/src/hooks/useDebounce.ts @@ -0,0 +1,16 @@ +import { useState, useEffect } from "react"; + +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} From 436e33d2274a656aa1850ef3ad16d5e046f27f0c Mon Sep 17 00:00:00 2001 From: Monixc Date: Sun, 10 Aug 2025 06:13:44 +0900 Subject: [PATCH 08/26] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EB=B0=8F=20=EC=B5=9C=EA=B7=BC=20=EA=B2=80=EC=83=89?= =?UTF-8?q?=EC=96=B4=20=EA=B4=80=EB=A6=AC=20=ED=9B=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useSearch 훅을 새로 생성하여 검색어 입력 및 최근 검색어 관리 기능 구현 - 검색 결과를 표시하는 SearchResults 컴포넌트 추가 - 검색어 입력 시 디바운스 처리 및 유효성 검사 기능 추가 - 최근 검색어 목록을 로컬 스토리지에 저장하고 불러오는 기능 구현 - 검색 페이지에서 검색 결과와 최근 검색어 목록을 조건에 따라 표시하도록 수정 --- src/hooks/useSearch.ts | 82 ++++++++++++++ src/lib/search.ts | 46 ++++++++ .../search/_components/SearchResults.tsx | 102 ++++++++++++++++++ src/pages/search/index.tsx | 64 ++++++----- 4 files changed, 269 insertions(+), 25 deletions(-) create mode 100644 src/hooks/useSearch.ts create mode 100644 src/lib/search.ts create mode 100644 src/pages/search/_components/SearchResults.tsx diff --git a/src/hooks/useSearch.ts b/src/hooks/useSearch.ts new file mode 100644 index 0000000..a888c5c --- /dev/null +++ b/src/hooks/useSearch.ts @@ -0,0 +1,82 @@ +import { useState, useEffect } from "react"; +import { useSearchPosts } from "@/hooks/queries/usePosts"; +import { useDebounce } from "@/hooks/useDebounce"; +import { + loadRecentSearches, + addRecentSearch, + removeRecentSearch as removeSearch, + isValidSearchQuery, +} from "@/lib/search"; + +export const useSearch = () => { + const [query, setQuery] = useState(""); + const [recentSearches, setRecentSearches] = useState([]); + const [manualSearch, setManualSearch] = useState(""); + + const debouncedQuery = useDebounce(query, 1000); + const searchQuery = manualSearch || debouncedQuery; + + const { + data: searchResults = [], + isLoading, + isError, + error, + } = useSearchPosts(searchQuery); + + useEffect(() => { + setRecentSearches(loadRecentSearches()); + }, []); + + const saveRecentSearch = (searchTerm: string) => { + setRecentSearches((prev) => addRecentSearch(searchTerm, prev)); + }; + + const handleRemoveRecentSearch = (index: number) => { + setRecentSearches((prev) => removeSearch(index, prev)); + }; + + useEffect(() => { + if (isValidSearchQuery(debouncedQuery)) { + saveRecentSearch(debouncedQuery.trim()); + if (manualSearch) { + setManualSearch(""); + } + } + }, [debouncedQuery, manualSearch]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (query.trim()) { + setManualSearch(query.trim()); + saveRecentSearch(query.trim()); + } + }; + + const handleRecentSearchClick = (search: string) => { + setQuery(search); + setManualSearch(search); + saveRecentSearch(search); + }; + + const hasSearchResults = searchQuery.trim().length > 0; + const hasQueryInput = query.trim().length > 0; + const isDebouncing = + hasQueryInput && query.trim() !== debouncedQuery.trim() && !manualSearch; + + return { + query, + recentSearches, + searchResults, + isLoading, + isError, + error, + hasSearchResults, + hasQueryInput, + isDebouncing, + searchQuery: hasSearchResults ? searchQuery : query, + setQuery, + handleSubmit, + handleRecentSearchClick, + removeRecentSearch: handleRemoveRecentSearch, + }; +}; diff --git a/src/lib/search.ts b/src/lib/search.ts new file mode 100644 index 0000000..61d8ed1 --- /dev/null +++ b/src/lib/search.ts @@ -0,0 +1,46 @@ +const RECENT_SEARCHES_KEY = "recent_searches"; + +export const loadRecentSearches = (): string[] => { + try { + const saved = localStorage.getItem(RECENT_SEARCHES_KEY); + return saved ? JSON.parse(saved) : []; + } catch (error) { + console.error("최근 검색어 로드 실패:", error); + return []; + } +}; + +export const saveRecentSearches = (searches: string[]): void => { + try { + localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(searches)); + } catch (error) { + console.error("최근 검색어 저장 실패:", error); + } +}; + +export const addRecentSearch = ( + searchTerm: string, + currentSearches: string[] +): string[] => { + const trimmedSearch = searchTerm.trim(); + if (!trimmedSearch) return currentSearches; + + const filtered = currentSearches.filter((item) => item !== trimmedSearch); + const updated = [trimmedSearch, ...filtered].slice(0, 10); + + saveRecentSearches(updated); + return updated; +}; + +export const removeRecentSearch = ( + index: number, + currentSearches: string[] +): string[] => { + const updated = currentSearches.filter((_, i) => i !== index); + saveRecentSearches(updated); + return updated; +}; + +export const isValidSearchQuery = (query: string): boolean => { + return query.trim().length >= 2; +}; diff --git a/src/pages/search/_components/SearchResults.tsx b/src/pages/search/_components/SearchResults.tsx new file mode 100644 index 0000000..33606e5 --- /dev/null +++ b/src/pages/search/_components/SearchResults.tsx @@ -0,0 +1,102 @@ +import { useNavigate } from "react-router-dom"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Heart, MessageCircle } from "lucide-react"; +import type { Post } from "@/types/post.types"; +import { formatPostListDate } from "@/lib/date"; + +interface SearchResultsProps { + posts: Post[]; + isLoading: boolean; + isError: boolean; + error: Error | null; + query: string; + isDebouncing?: boolean; +} + +export default function SearchResults({ + posts, + isLoading, + isError, + error, + query, + isDebouncing = false, +}: SearchResultsProps) { + const navigate = useNavigate(); + + const handlePostClick = (post: Post) => { + navigate(`/community/${post.id}`); + }; + + if (isLoading || isDebouncing) { + return ( +
+

{isDebouncing ? "검색어 입력 중..." : "검색 중..."}

+
+ ); + } + + if (isError) { + return ( +
+

검색 중 오류가 발생했습니다:

+

{error?.message}

+
+ ); + } + + if (posts.length === 0 && query.trim()) { + return ( +
+

"{query}"에 대한 검색 결과가 없습니다.

+
+ ); + } + + if (!query.trim()) { + return null; + } + + return ( +
+
+

+ "{query}" 검색 결과 {posts.length}개 +

+
+ +
+ {posts.map((post) => ( + handlePostClick(post)}> + + + {post.board.value} + + + {post.title} + + + + + {post.author.nickname} · {formatPostListDate(post.createdAt)} + +
+
+ + {post.nLikes} +
+
+ + {post.nComments} +
+
+
+
+ ))} +
+
+ ); +} diff --git a/src/pages/search/index.tsx b/src/pages/search/index.tsx index b141572..758d966 100644 --- a/src/pages/search/index.tsx +++ b/src/pages/search/index.tsx @@ -1,42 +1,56 @@ -import { useState } from "react"; +import { useNavigate } from "react-router-dom"; import RecentSearchList from "@/pages/search/_components/RecentSearchList"; import SearchHeader from "@/pages/search/_components/SearchHeader"; +import SearchResults from "@/pages/search/_components/SearchResults"; +import { useSearch } from "@/hooks/useSearch"; export default function SearchPage() { - const [query, setQuery] = useState(""); - const [recentSearches, setRecentSearches] = useState([ - "이전 검색 결과 1", - "이전 검색 결과 2", - "이전 검색 결과 3", - ]); + const navigate = useNavigate(); + const { + query, + recentSearches, + searchResults, + isLoading, + isError, + error, + hasQueryInput, + isDebouncing, + searchQuery, + setQuery, + handleSubmit, + handleRecentSearchClick, + removeRecentSearch, + } = useSearch(); - const handleRemoveSearch = (index: number) => { - setRecentSearches((prev) => prev.filter((_, i) => i !== index)); - }; - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (query.trim()) { - // TODO: 실제 검색 API 연동 후 검색 결과 페이지로 이동 또는 결과 표시 - console.log("검색:", query); - } + const handleBack = () => { + navigate(-1); }; return ( -
+
{}} + onBack={handleBack} />
- + {hasQueryInput ? ( + + ) : ( + + )}
); From a18ee51227b20f0db2218ece3299d3c2d1329947 Mon Sep 17 00:00:00 2001 From: Monixc Date: Sun, 10 Aug 2025 23:07:12 +0900 Subject: [PATCH 09/26] =?UTF-8?q?feat:=20=EC=BB=A4=EB=AE=A4=EB=8B=88?= =?UTF-8?q?=ED=8B=B0=20=EA=B4=80=EB=A0=A8=20=ED=83=80=EC=9E=85=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존 community.types.ts 파일 삭제 - 새로운 my.types.ts 파일 생성 및 프로필 관련 타입 정의 추가 - post.types.ts 파일에서 Post 인터페이스를 PostListResponse 및 PostDetailResponse로 변경하고, 댓글 및 좋아요 관련 응답 타입 추가 --- src/types/community.types.ts | 26 --------------- src/types/my.types.ts | 45 ++++++++++++++++++++++++++ src/types/post.types.ts | 63 +++++++++++++++++++++++++++++++++++- 3 files changed, 107 insertions(+), 27 deletions(-) delete mode 100644 src/types/community.types.ts create mode 100644 src/types/my.types.ts diff --git a/src/types/community.types.ts b/src/types/community.types.ts deleted file mode 100644 index 2638472..0000000 --- a/src/types/community.types.ts +++ /dev/null @@ -1,26 +0,0 @@ -export interface Comment { - id: number; - postId: number; - author: { - nickname: string; - avatar: string; - }; - content: string; - editable: boolean; - createdAt: string; -} - -export interface Post { - id: number; - category: string; - title: string; - author: { - name: string; - avatarUrl: string; - }; - createdAt: string; - content: string; - likeCount: number; - isLiked: boolean; - comments: Comment[]; -} diff --git a/src/types/my.types.ts b/src/types/my.types.ts new file mode 100644 index 0000000..fe6a85d --- /dev/null +++ b/src/types/my.types.ts @@ -0,0 +1,45 @@ +export interface GetProfileResponse { + nickname: string; + avatar: string; + age: number; + experience: number; + bio: string; + position: { + id: number; + field: string; + role: string; + }; + techStack: Array<{ + id: number; + value: string; + }>; + interests: Array<{ + id: number; + value: string; + }>; + location: { + lng: number; + lat: number; + }; + github?: string; + email?: string; + instagram?: string; + linkedIn?: string; + blog?: string; + nSubscribers: number; + subscribing: boolean; +} + +export interface UpdateProfileRequest { + nickname?: string; + experience?: number; + bio?: string; + positionId?: number; + techIds?: number[]; + interestIds?: number[]; + github?: string; + email?: string; + instagram?: string; + linkedIn?: string; + blog?: string; +} diff --git a/src/types/post.types.ts b/src/types/post.types.ts index 5b2f11d..cb01ea7 100644 --- a/src/types/post.types.ts +++ b/src/types/post.types.ts @@ -1,4 +1,21 @@ -export interface Post { +export interface PostListResponse { + id: number; + board: { + id: number; + value: string; + isRecruit: boolean; + }; + author: { + avatar: string; + nickname: string; + }; + title: string; + nComments: number; + nLikes: number; + createdAt: string; +} + +export interface PostDetailResponse { id: number; board: { id: number; @@ -14,4 +31,48 @@ export interface Post { nComments: number; nLikes: number; createdAt: string; + editable: boolean; + likeIt: boolean; + bookmark: boolean; +} + +export interface CommentResponse { + id: number; + postId: number; + author: { + nickname: string; + avatar: string; + }; + content: string; + editable: boolean; + createdAt: string; +} + +export interface LikeToggleResponse { + nLikes: number; + likeIt: boolean; +} + +export interface BookmarkToggleResponse { + bookmark: boolean; +} + +export interface PostCommentResponse { + nComments: number; +} + +// ===== Request Types ===== + +// 게시글 생성 요청 +export interface CreatePostRequest { + title: string; + content: string; + location: { + lng: number; + lat: number; + }; +} + +export interface CreateCommentRequest { + content: string; } From eb2ac0f8c3ca1ff6a3aebade4d402bf1f9125e39 Mon Sep 17 00:00:00 2001 From: Monixc Date: Sun, 10 Aug 2025 23:38:27 +0900 Subject: [PATCH 10/26] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EB=B0=8F=20=EB=8C=93=EA=B8=80=20=EA=B4=80=EB=A0=A8=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=ED=83=80=EC=9E=85?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 게시글 상세 조회, 댓글 조회, 댓글 생성, 좋아요 및 북마크 토글 기능을 위한 훅(useToggleLike, useToggleBookmark, useCreateComment) 추가 - API 호출(fetchPostDetail, fetchComments, createComment, toggleLike, toggleBookmark) 수정 및 추가 - 타입 정의(PostDetailResponse, CommentResponse 등) 수정 및 업데이트 - 댓글 입력 시 Enter 키로 제출 가능하도록 기능 추가 - UI 컴포넌트(PostContent, CommentSection 등)에서 새로운 기능 및 타입 반영 --- src/hooks/queries/usePosts.ts | 84 +++++++++++++- src/lib/api/posts.ts | 104 +++++++++++++++--- .../community/__components/CommentForm.tsx | 8 ++ .../community/__components/CommentItem.tsx | 4 +- .../community/__components/CommentSection.tsx | 26 ++--- .../community/__components/PostContent.tsx | 20 ++-- src/pages/community/index.tsx | 82 +++++++++++--- 7 files changed, 259 insertions(+), 69 deletions(-) diff --git a/src/hooks/queries/usePosts.ts b/src/hooks/queries/usePosts.ts index d97b29d..ece1317 100644 --- a/src/hooks/queries/usePosts.ts +++ b/src/hooks/queries/usePosts.ts @@ -6,10 +6,16 @@ import { fetchPostDetail, searchPosts, fetchComments, + createComment, + toggleLike, + toggleBookmark, } from "@/lib/api/posts"; import type { UICategory } from "@/constants/board"; -import type { Post } from "@/types/post.types"; -import type { Comment } from "@/types/community.types"; +import type { + CommentResponse, + PostDetailResponse, + PostCommentResponse, +} from "@/types/post.types"; export const useBoards = () => { return useQuery({ @@ -30,7 +36,7 @@ export const usePosts = (category: UICategory) => { }; export const usePostDetail = (postId: string) => { - return useQuery({ + return useQuery({ queryKey: ["post", postId], queryFn: async () => { const result = await fetchPostDetail(postId); @@ -68,7 +74,7 @@ export const useCreatePost = () => { }; export const useComments = (postId: string) => { - return useQuery({ + return useQuery({ queryKey: ["comments", postId], queryFn: () => fetchComments(postId), enabled: !!postId, @@ -76,3 +82,73 @@ export const useComments = (postId: string) => { gcTime: 5 * 60 * 1000, }); }; + +export const useToggleLike = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: toggleLike, + onSuccess: (data, variables) => { + queryClient.invalidateQueries({ queryKey: ["posts"] }); + queryClient.invalidateQueries({ queryKey: ["search"] }); + queryClient.setQueryData( + ["post", variables.toString()], + (oldData: PostDetailResponse | undefined) => { + if (!oldData) return oldData; + return { + ...oldData, + nLikes: data.nLikes, + likeIt: data.likeIt, + }; + } + ); + }, + }); +}; + +export const useToggleBookmark = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: toggleBookmark, + onSuccess: (data, variables) => { + queryClient.invalidateQueries({ queryKey: ["posts"] }); + queryClient.invalidateQueries({ queryKey: ["search"] }); + queryClient.setQueryData( + ["post", variables.toString()], + (oldData: PostDetailResponse | undefined) => { + if (!oldData) return oldData; + return { + ...oldData, + bookmark: data.bookmark, + }; + } + ); + }, + }); +}; + +export const useCreateComment = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ postId, content }: { postId: string; content: string }) => + createComment(postId, content), + onSuccess: (data: PostCommentResponse, variables) => { + queryClient.invalidateQueries({ + queryKey: ["comments", variables.postId], + }); + queryClient.invalidateQueries({ queryKey: ["posts"] }); + queryClient.setQueryData( + ["post", variables.postId], + (oldData: PostDetailResponse | undefined) => { + if (!oldData) return oldData; + return { + ...oldData, + nComments: data.nComments, + }; + } + ); + }, + }); +}; diff --git a/src/lib/api/posts.ts b/src/lib/api/posts.ts index cdb774d..9bb9f7d 100644 --- a/src/lib/api/posts.ts +++ b/src/lib/api/posts.ts @@ -1,5 +1,11 @@ -import type { Post } from "@/types/post.types"; -import type { Comment } from "@/types/community.types"; +import type { + CommentResponse, + PostDetailResponse, + CreatePostRequest, + LikeToggleResponse, + BookmarkToggleResponse, + PostCommentResponse, +} from "@/types/post.types"; import type { UICategory } from "@/constants/board"; import { createApiUrl, createAuthHeaders } from "./config"; @@ -23,7 +29,9 @@ export const fetchBoards = async (): Promise => { }; //게시글 목록 조회회 -export const fetchPosts = async (category: UICategory): Promise => { +export const fetchPosts = async ( + category: UICategory +): Promise => { const url = category === "all" ? `${createApiUrl("/posts/search")}` @@ -41,22 +49,15 @@ export const fetchPosts = async (category: UICategory): Promise => { return data.results || []; }; -//게시글 생성성 -export const createPost = async (data: { - title: string; - content: string; - boardId: number; -}): Promise => { - const response = await fetch(`${createApiUrl(`/posts/${data.boardId}`)}`, { +//게시글 생성 +export const createPost = async (data: CreatePostRequest): Promise => { + const response = await fetch(`${createApiUrl(`/posts`)}`, { method: "POST", headers: createAuthHeaders(), body: JSON.stringify({ title: data.title, content: data.content, - location: { - lng: 127.0, - lat: 37.5, - }, + location: data.location, }), }); @@ -66,7 +67,9 @@ export const createPost = async (data: { }; //게시글 상세 조회 -export const fetchPostDetail = async (postId: string): Promise => { +export const fetchPostDetail = async ( + postId: string +): Promise => { const response = await fetch(`${createApiUrl(`/posts/details/${postId}`)}`, { headers: createAuthHeaders(), }); @@ -80,8 +83,10 @@ export const fetchPostDetail = async (postId: string): Promise => { }; // 댓글 조회 -export const fetchComments = async (postId: string): Promise => { - const response = await fetch(`${createApiUrl(`/posts/${postId}`)}`, { +export const fetchComments = async ( + postId: string +): Promise => { + const response = await fetch(`${createApiUrl(`/posts/${postId}/comments`)}`, { headers: createAuthHeaders(), }); @@ -93,8 +98,36 @@ export const fetchComments = async (postId: string): Promise => { return data.results || []; }; +// 댓글 생성 +export const createComment = async ( + postId: string, + content: string +): Promise => { + const url = `${createApiUrl(`/posts/${postId}/comments`)}`; + const headers = createAuthHeaders(); + const body = JSON.stringify({ content }); + + const response = await fetch(url, { + method: "POST", + headers, + body, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `댓글 작성에 실패했습니다: ${response.status} ${response.statusText} - ${errorText}` + ); + } + + const data = await response.json(); + return data; +}; + // 게시글 검색 -export const searchPosts = async (query: string): Promise => { +export const searchPosts = async ( + query: string +): Promise => { const response = await fetch( `${createApiUrl(`/posts/search?query=${encodeURIComponent(query)}`)}`, { @@ -109,3 +142,38 @@ export const searchPosts = async (query: string): Promise => { const data = await response.json(); return data.results || []; }; + +//좋아용용 +export const toggleLike = async ( + postId: number +): Promise => { + const response = await fetch(`${createApiUrl(`/posts/${postId}/likes`)}`, { + method: "PUT", + headers: createAuthHeaders(), + }); + + if (!response.ok) { + throw new Error("좋아요 처리에 실패했습니다."); + } + + return response.json(); +}; + +//북마크 +export const toggleBookmark = async ( + postId: number +): Promise => { + const response = await fetch( + `${createApiUrl(`/posts/${postId}/bookmarks`)}`, + { + method: "PUT", + headers: createAuthHeaders(), + } + ); + + if (!response.ok) { + throw new Error("북마크 처리에 실패했습니다."); + } + + return response.json(); +}; diff --git a/src/pages/community/__components/CommentForm.tsx b/src/pages/community/__components/CommentForm.tsx index 92c2d48..5005738 100644 --- a/src/pages/community/__components/CommentForm.tsx +++ b/src/pages/community/__components/CommentForm.tsx @@ -16,12 +16,20 @@ export function CommentForm({ onSubmit, isPending }: CommentFormProps) { setComment(""); }; + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + }; + return (
setComment(e.target.value)} + onKeyPress={handleKeyPress} className="flex-1 bg-brand-surface border-none rounded-md px-4 py-2 resize-none no-scrollbar text-sm text-white ring-0" disabled={isPending} /> diff --git a/src/pages/community/__components/CommentItem.tsx b/src/pages/community/__components/CommentItem.tsx index 636d104..1078cee 100644 --- a/src/pages/community/__components/CommentItem.tsx +++ b/src/pages/community/__components/CommentItem.tsx @@ -1,8 +1,8 @@ -import type { Comment } from "@/types/community.types"; +import type { CommentResponse } from "@/types/post.types"; import { formatCommentDate } from "@/lib/date"; interface CommentItemProps { - comment: Comment; + comment: CommentResponse; } export function CommentItem({ comment }: CommentItemProps) { diff --git a/src/pages/community/__components/CommentSection.tsx b/src/pages/community/__components/CommentSection.tsx index d6a64be..e9702b0 100644 --- a/src/pages/community/__components/CommentSection.tsx +++ b/src/pages/community/__components/CommentSection.tsx @@ -1,17 +1,11 @@ -import type { Comment } from "@/types/community.types"; +import type { CommentResponse } from "@/types/post.types"; import { CommentItem } from "./CommentItem"; interface CommentSectionProps { - comments: Comment[]; - onToggleCommentLike: (commentId: number) => void; - onToggleReplyLike: (replyId: number) => void; + comments: CommentResponse[]; } -export function CommentSection({ - comments, - onToggleCommentLike, - onToggleReplyLike, -}: CommentSectionProps) { +export function CommentSection({ comments }: CommentSectionProps) { const totalComments = comments.length; return ( @@ -19,14 +13,12 @@ export function CommentSection({

댓글 {totalComments}

- {comments.map((comment) => ( - - ))} + {comments + .slice() + .reverse() + .map((comment) => ( + + ))}
); diff --git a/src/pages/community/__components/PostContent.tsx b/src/pages/community/__components/PostContent.tsx index c261db6..c27ffe8 100644 --- a/src/pages/community/__components/PostContent.tsx +++ b/src/pages/community/__components/PostContent.tsx @@ -1,11 +1,11 @@ -import type { Post } from "@/types/community.types"; +import type { PostDetailResponse } from "@/types/post.types"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; import { Button } from "@/components/ui/button"; import { Heart } from "lucide-react"; interface PostContentProps { - post: Post; + post: PostDetailResponse; onLikeClick: () => void; isLikePending: boolean; } @@ -17,11 +17,11 @@ export function PostContent({ post, onLikeClick }: PostContentProps) { - {post.category} + {post.board.value}

{post.title}

- {post.author.name} + {post.author.nickname} {post.createdAt}
@@ -33,21 +33,21 @@ export function PostContent({ post, onLikeClick }: PostContentProps) {
{post.content}
-
+
- - {post.likeCount} + + {post.nLikes}
diff --git a/src/pages/community/index.tsx b/src/pages/community/index.tsx index e43a2b1..0a654cd 100644 --- a/src/pages/community/index.tsx +++ b/src/pages/community/index.tsx @@ -3,7 +3,14 @@ import Header from "@/components/layout/Header"; import { ArrowLeft, Bookmark, Heart, MessageCircle } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; -import { usePostDetail, useComments } from "@/hooks/queries/usePosts"; +import { Button } from "@/components/ui/button"; +import { + usePostDetail, + useComments, + useToggleLike, + useToggleBookmark, + useCreateComment, +} from "@/hooks/queries/usePosts"; import { CommentForm } from "./__components/CommentForm"; import { CommentSection } from "./__components/CommentSection"; import { formatPostDetailDate } from "@/lib/date"; @@ -23,6 +30,10 @@ export function PostDetailPage() { isError: isCommentsError, } = useComments(postId); + const toggleLikeMutation = useToggleLike(); + const toggleBookmarkMutation = useToggleBookmark(); + const createCommentMutation = useCreateComment(); + if (isLoading) { return
로딩 중...
; } @@ -37,8 +48,32 @@ export function PostDetailPage() { navigate(-1); }; - const handleBookmarkClick = () => { - console.log("북마크 클릭"); + const handleLikeClick = async () => { + if (!post) return; + try { + await toggleLikeMutation.mutateAsync(post.id); + } catch (error) { + console.error("좋아요 처리 실패:", error); + } + }; + + const handleBookmarkClick = async () => { + if (!post) return; + try { + await toggleBookmarkMutation.mutateAsync(post.id); + } catch (error) { + console.error("북마크 처리 실패:", error); + } + }; + + const handleCommentSubmit = async (content: string) => { + if (!postId) return; + try { + await createCommentMutation.mutateAsync({ postId, content }); + } catch (error) { + console.error("댓글 작성 실패:", error); + alert("댓글 작성에 실패했습니다. 다시 시도해주세요."); + } }; return ( @@ -46,7 +81,13 @@ export function PostDetailPage() {
} - rightIcon={} + rightIcon={ + + } onLeftClick={handleBackClick} onRightClick={handleBookmarkClick} /> @@ -73,17 +114,24 @@ export function PostDetailPage() { {post.content ? ( <>{post.content} ) : ( -

- 게시글 내용은 현재 API에서 제공되지 않습니다. -

+

게시글 내용이 없습니다.

)}
-
-
- +
+
+
{post.nComments} @@ -101,18 +149,16 @@ export function PostDetailPage() { 댓글을 불러오는 데 실패했습니다.
) : ( - {}} - onToggleReplyLike={() => {}} - /> + )} -
- {}} isPending={false} /> +
); From 683382a30d296b35c290c8d2848d839ddc822562 Mon Sep 17 00:00:00 2001 From: Monixc Date: Mon, 11 Aug 2025 03:43:42 +0900 Subject: [PATCH 11/26] =?UTF-8?q?chore:=20alert-dialong,=20dropdwon-menu?= =?UTF-8?q?=20=EC=84=A4=EC=B9=98=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 101 ++++++++++- package.json | 4 +- src/components/ui/alert-dialog.tsx | 139 +++++++++++++++ src/components/ui/dropdown-menu.tsx | 255 ++++++++++++++++++++++++++++ 4 files changed, 497 insertions(+), 2 deletions(-) create mode 100644 src/components/ui/alert-dialog.tsx create mode 100644 src/components/ui/dropdown-menu.tsx diff --git a/package-lock.json b/package-lock.json index c52c7de..e592abd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,14 +9,16 @@ "version": "0.0.0", "dependencies": { "@radix-ui/react-accordion": "^1.2.0", + "@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-dialog": "^1.1.1", + "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.1", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slider": "^1.3.5", - "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-toggle": "^1.1.0", "@tanstack/react-query": "^5.83.0", @@ -623,6 +625,34 @@ } } }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.14.tgz", + "integrity": "sha512-IOZfZ3nPvN6lXpJTBCunFQPRSvK8MDgSc1FB85xnIpUKOw9en0dJj8JmCAxV7BiZdtYlUpmrQjoTFkVYtdoWzQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.14", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.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-arrow": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", @@ -837,6 +867,35 @@ } } }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.15.tgz", + "integrity": "sha512-mIBnOjgwo9AH3FyKaSWoSu/dYj6VdhJ7frEPiGTeXCdUFHjl9h3mFh2wwhEtINOmYXWhdpf1rY2minFsmaNgVQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "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-focus-guards": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz", @@ -918,6 +977,46 @@ } } }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.15.tgz", + "integrity": "sha512-tVlmA3Vb9n8SZSd+YSbuFR66l87Wiy4du+YE+0hzKQEANA+7cWKH1WgqcEX4pXqxUFQKrWQGHdvEfw00TjFiew==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.10", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.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-popover": { "version": "1.1.14", "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.14.tgz", diff --git a/package.json b/package.json index 8f8e98b..2e8d867 100644 --- a/package.json +++ b/package.json @@ -11,14 +11,16 @@ }, "dependencies": { "@radix-ui/react-accordion": "^1.2.0", + "@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-dialog": "^1.1.1", + "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.1", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slider": "^1.3.5", - "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-toggle": "^1.1.0", "@tanstack/react-query": "^5.83.0", diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..1084119 --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,139 @@ +import * as React from "react"; +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; + +import { cn } from "@/lib/utils"; +import { buttonVariants } from "@/components/ui/button"; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogHeader.displayName = "AlertDialogHeader"; + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogFooter.displayName = "AlertDialogFooter"; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..0d6741b --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,255 @@ +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} From b5f83a4bc4ae6204586cb4d20ff45bc43ab48957 Mon Sep 17 00:00:00 2001 From: Monixc Date: Mon, 11 Aug 2025 04:35:02 +0900 Subject: [PATCH 12/26] =?UTF-8?q?feat:=20=ED=8F=AC=EC=A7=80=EC=85=98=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=EA=B8=B0=20=EB=B0=8F=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EA=B8=80=20=EA=B4=80=EB=A0=A8=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PositionSelector 컴포넌트에 다중 선택 기능 추가 및 관련 props 수정 - useAddPostModal 훅에 선택된 포지션 ID 배열 및 위치 정보 추가 - usePosts 훅에 지원자 목록 조회 및 지원 상태 반전 기능 추가 - 게시글 삭제 및 댓글 수정/삭제 기능을 위한 API 및 UI 컴포넌트 추가 - 댓글 작성 및 수정 시 취소 기능 추가 - ApplicantSection 컴포넌트 추가로 지원자 목록 표시 기능 구현 --- src/components/common/PositionSelector.tsx | 53 ++- src/hooks/queries/usePosts.ts | 150 ++++++++- src/hooks/useAddPostModal.ts | 24 ++ src/lib/api/posts.ts | 107 +++++- .../__components/ApplicantSection.tsx | 34 ++ .../community/__components/CommentForm.tsx | 32 +- .../community/__components/CommentItem.tsx | 41 ++- .../community/__components/CommentSection.tsx | 15 +- .../community/__components/PostContent.tsx | 78 +++-- src/pages/community/index.tsx | 306 +++++++++++++++--- src/types/post.types.ts | 31 +- 11 files changed, 780 insertions(+), 91 deletions(-) create mode 100644 src/pages/community/__components/ApplicantSection.tsx diff --git a/src/components/common/PositionSelector.tsx b/src/components/common/PositionSelector.tsx index 8e04ad8..7bdbfea 100644 --- a/src/components/common/PositionSelector.tsx +++ b/src/components/common/PositionSelector.tsx @@ -12,14 +12,20 @@ import type { Position } from "@/types/tags.types"; interface PositionSelectorProps { selectedPosition: number | null; + selectedPositionIds?: number[]; onPositionChange: (positionId: number) => void; + onPositionIdsChange?: (positionIds: number[]) => void; label?: string; + multiple?: boolean; } export const PositionSelector = ({ selectedPosition, + selectedPositionIds = [], onPositionChange, + onPositionIdsChange, label = "포지션", + multiple = false, }: PositionSelectorProps) => { const { data: positionsInterestsData, isLoading } = usePositionsInterests(); @@ -59,21 +65,38 @@ export const PositionSelector = ({
- {fieldPositions.map((position) => ( - - ))} + {fieldPositions.map((position) => { + const isSelected = multiple + ? selectedPositionIds.includes(position.id) + : selectedPosition === position.id; + + const handleClick = () => { + if (multiple && onPositionIdsChange) { + const newPositionIds = isSelected + ? selectedPositionIds.filter((id) => id !== position.id) + : [...selectedPositionIds, position.id]; + onPositionIdsChange(newPositionIds); + } else { + onPositionChange(position.id); + } + }; + + return ( + + ); + })}
diff --git a/src/hooks/queries/usePosts.ts b/src/hooks/queries/usePosts.ts index ece1317..ffb4f7b 100644 --- a/src/hooks/queries/usePosts.ts +++ b/src/hooks/queries/usePosts.ts @@ -9,12 +9,20 @@ import { createComment, toggleLike, toggleBookmark, + fetchApplicants, + toggleApply, + deletePost, + updateComment, + deleteComment, } from "@/lib/api/posts"; import type { UICategory } from "@/constants/board"; import type { CommentResponse, PostDetailResponse, + PostListItem, + PostDetailItem, PostCommentResponse, + ApplicantResponse, } from "@/types/post.types"; export const useBoards = () => { @@ -27,16 +35,22 @@ export const useBoards = () => { }; export const usePosts = (category: UICategory) => { - return useQuery({ + return useQuery({ queryKey: ["posts", category], - queryFn: () => fetchPosts(category), + queryFn: async () => { + const posts = await fetchPosts(category); + return posts.sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); + }, staleTime: 5 * 60 * 1000, gcTime: 10 * 60 * 1000, }); }; export const usePostDetail = (postId: string) => { - return useQuery({ + return useQuery({ queryKey: ["post", postId], queryFn: async () => { const result = await fetchPostDetail(postId); @@ -55,7 +69,13 @@ export const usePostDetail = (postId: string) => { export const useSearchPosts = (query: string) => { return useQuery({ queryKey: ["search", query], - queryFn: () => searchPosts(query), + queryFn: async () => { + const posts = await searchPosts(query); + return posts.sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); + }, enabled: !!query && query.trim().length > 0, staleTime: 2 * 60 * 1000, gcTime: 5 * 60 * 1000, @@ -152,3 +172,125 @@ export const useCreateComment = () => { }, }); }; + +export const useApplicants = (postId: string) => { + return useQuery({ + queryKey: ["applicants", postId], + queryFn: () => fetchApplicants(postId), + enabled: !!postId, + staleTime: 2 * 60 * 1000, + gcTime: 5 * 60 * 1000, + }); +}; + +export const useToggleApply = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: toggleApply, + onSuccess: (data, variables) => { + queryClient.invalidateQueries({ queryKey: ["posts"] }); + queryClient.invalidateQueries({ + queryKey: ["applicants", variables.toString()], + }); + + queryClient.setQueryData( + ["post", variables.toString()], + (oldData: PostDetailItem | undefined) => { + if (!oldData) return oldData; + return { + ...oldData, + applied: data.applied, + nApplicants: data.applied + ? (oldData.nApplicants || 0) + 1 + : Math.max((oldData.nApplicants || 0) - 1, 0), + }; + } + ); + + queryClient.setQueriesData( + { queryKey: ["posts"] }, + (oldData: PostListItem[] | undefined) => { + if (!oldData) return oldData; + return oldData.map((post) => { + if (post.id === variables) { + return { + ...post, + nApplicants: data.applied + ? (post.nApplicants || 0) + 1 + : Math.max((post.nApplicants || 0) - 1, 0), + }; + } + return post; + }); + } + ); + }, + }); +}; + +export const useDeletePost = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: deletePost, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["posts"] }); + queryClient.invalidateQueries({ queryKey: ["search"] }); + }, + }); +}; + +export const useUpdateComment = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + commentId, + content, + }: { + commentId: number; + content: string; + }) => updateComment(commentId, { content }), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: ["comments"] }); + queryClient.setQueriesData( + { queryKey: ["comments"] }, + (oldData: CommentResponse[] | undefined) => { + if (!oldData) return oldData; + return oldData.map((comment) => { + if (comment.id === variables.commentId) { + return { + ...comment, + content: variables.content, + }; + } + return comment; + }); + } + ); + }, + }); +}; + +export const useDeleteComment = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: deleteComment, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["comments"] }); + queryClient.invalidateQueries({ queryKey: ["posts"] }); + queryClient.setQueriesData( + { queryKey: ["post"] }, + (oldData: PostDetailItem | undefined) => { + if (!oldData) return oldData; + return { + ...oldData, + nComments: Math.max((oldData.nComments || 0) - 1, 0), + }; + } + ); + }, + }); +}; diff --git a/src/hooks/useAddPostModal.ts b/src/hooks/useAddPostModal.ts index f4f7d90..22ff88a 100644 --- a/src/hooks/useAddPostModal.ts +++ b/src/hooks/useAddPostModal.ts @@ -7,7 +7,9 @@ interface AddPostModalState { description: string; recruitCount: number; position: number | null; + selectedPositionIds: number[]; selectedStackIds: number[]; + location: { lat: number; lng: number } | null; } interface AddPostModalActions { @@ -17,7 +19,9 @@ interface AddPostModalActions { setDescription: (description: string) => void; setRecruitCount: (count: number) => void; setPosition: (position: number | null) => void; + setSelectedPositionIds: (positionIds: number[]) => void; setSelectedStackIds: (stackIds: number[]) => void; + setLocation: (location: { lat: number; lng: number } | null) => void; reset: () => void; } @@ -30,7 +34,9 @@ export const useAddPostModal = (): AddPostModalState & description: "", recruitCount: 1, position: null, + selectedPositionIds: [], selectedStackIds: [], + location: null, }); const setTitle = useCallback((title: string) => { @@ -57,10 +63,24 @@ export const useAddPostModal = (): AddPostModalState & setState((prev) => ({ ...prev, position })); }, []); + const setSelectedPositionIds = useCallback( + (selectedPositionIds: number[]) => { + setState((prev) => ({ ...prev, selectedPositionIds })); + }, + [] + ); + const setSelectedStackIds = useCallback((selectedStackIds: number[]) => { setState((prev) => ({ ...prev, selectedStackIds })); }, []); + const setLocation = useCallback( + (location: { lat: number; lng: number } | null) => { + setState((prev) => ({ ...prev, location })); + }, + [] + ); + const reset = useCallback(() => { setState({ title: "", @@ -69,7 +89,9 @@ export const useAddPostModal = (): AddPostModalState & description: "", recruitCount: 1, position: null, + selectedPositionIds: [], selectedStackIds: [], + location: null, }); }, []); @@ -84,7 +106,9 @@ export const useAddPostModal = (): AddPostModalState & setDescription, setRecruitCount, setPosition, + setSelectedPositionIds, setSelectedStackIds, + setLocation, reset, isRecruitBoard, }; diff --git a/src/lib/api/posts.ts b/src/lib/api/posts.ts index 9bb9f7d..94bd12b 100644 --- a/src/lib/api/posts.ts +++ b/src/lib/api/posts.ts @@ -5,6 +5,10 @@ import type { LikeToggleResponse, BookmarkToggleResponse, PostCommentResponse, + ApplicantResponse, + ApplyToggleResponse, + UpdatePostRequest, + UpdateCommentRequest, } from "@/types/post.types"; import type { UICategory } from "@/constants/board"; import { createApiUrl, createAuthHeaders } from "./config"; @@ -28,7 +32,7 @@ export const fetchBoards = async (): Promise => { return data.results || []; }; -//게시글 목록 조회회 +//게시글 목록 조회 export const fetchPosts = async ( category: UICategory ): Promise => { @@ -51,7 +55,7 @@ export const fetchPosts = async ( //게시글 생성 export const createPost = async (data: CreatePostRequest): Promise => { - const response = await fetch(`${createApiUrl(`/posts`)}`, { + const response = await fetch(`${createApiUrl(`/posts/${data.boardId}`)}`, { method: "POST", headers: createAuthHeaders(), body: JSON.stringify({ @@ -129,7 +133,7 @@ export const searchPosts = async ( query: string ): Promise => { const response = await fetch( - `${createApiUrl(`/posts/search?query=${encodeURIComponent(query)}`)}`, + `${createApiUrl(`/posts/search?keyword=${encodeURIComponent(query)}`)}`, { headers: createAuthHeaders(), } @@ -177,3 +181,100 @@ export const toggleBookmark = async ( return response.json(); }; + +// 지원자 목록 조회 +export const fetchApplicants = async ( + postId: string +): Promise => { + const response = await fetch( + `${createApiUrl(`/posts/${postId}/applicants`)}`, + { + headers: createAuthHeaders(), + } + ); + + if (!response.ok) { + throw new Error("지원자 목록을 불러오는 데 실패했습니다."); + } + + const data = await response.json(); + return data.results || []; +}; + +// 지원 상태 반전 +export const toggleApply = async ( + postId: number +): Promise => { + const response = await fetch(`${createApiUrl(`/posts/${postId}/applies`)}`, { + method: "PUT", + headers: createAuthHeaders(), + }); + + if (!response.ok) { + throw new Error("지원 처리에 실패했습니다."); + } + + return response.json(); +}; + +// // 게시글 수정 +// export const updatePost = async ( +// postId: number, +// data: UpdatePostRequest +// ): Promise => { +// const response = await fetch(`${createApiUrl(`/posts/posts/${postId}`)}`, { +// method: "PATCH", +// headers: createAuthHeaders(), +// body: JSON.stringify(data), +// }); + +// if (!response.ok) { +// throw new Error("게시글 수정에 실패했습니다."); +// } +// }; + +// 게시글 삭제 +export const deletePost = async (postId: number): Promise => { + const response = await fetch(`${createApiUrl(`/posts/posts/${postId}`)}`, { + method: "DELETE", + headers: createAuthHeaders(), + }); + + if (!response.ok) { + throw new Error("게시글 삭제에 실패했습니다."); + } +}; + +// 댓글 수정 +export const updateComment = async ( + commentId: number, + data: UpdateCommentRequest +): Promise => { + const response = await fetch( + `${createApiUrl(`/posts/comments/${commentId}`)}`, + { + method: "PATCH", + headers: createAuthHeaders(), + body: JSON.stringify(data), + } + ); + + if (!response.ok) { + throw new Error("댓글 수정에 실패했습니다."); + } +}; + +// 댓글 삭제 +export const deleteComment = async (commentId: number): Promise => { + const response = await fetch( + `${createApiUrl(`/posts/comments/${commentId}`)}`, + { + method: "DELETE", + headers: createAuthHeaders(), + } + ); + + if (!response.ok) { + throw new Error("댓글 삭제에 실패했습니다."); + } +}; diff --git a/src/pages/community/__components/ApplicantSection.tsx b/src/pages/community/__components/ApplicantSection.tsx new file mode 100644 index 0000000..de7eaa4 --- /dev/null +++ b/src/pages/community/__components/ApplicantSection.tsx @@ -0,0 +1,34 @@ +import type { ApplicantResponse } from "@/types/post.types"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; + +interface ApplicantSectionProps { + applicants: ApplicantResponse[]; +} + +export function ApplicantSection({ applicants }: ApplicantSectionProps) { + const totalApplicants = applicants.length; + + return ( +
+

지원자 {totalApplicants}

+ +
+ {applicants.length > 0 ? ( + applicants.map((applicant, index) => ( +
+ + + + {applicant.nickname.charAt(0)} + + + {applicant.nickname} +
+ )) + ) : ( +

아직 지원자가 없습니다.

+ )} +
+
+ ); +} diff --git a/src/pages/community/__components/CommentForm.tsx b/src/pages/community/__components/CommentForm.tsx index 5005738..4d32d1c 100644 --- a/src/pages/community/__components/CommentForm.tsx +++ b/src/pages/community/__components/CommentForm.tsx @@ -5,10 +5,19 @@ import { Input } from "@/components/ui/input"; interface CommentFormProps { onSubmit: (content: string) => void; isPending: boolean; + editingCommentId?: number | null; + initialContent?: string; + onCancel?: () => void; } -export function CommentForm({ onSubmit, isPending }: CommentFormProps) { - const [comment, setComment] = useState(""); +export function CommentForm({ + onSubmit, + isPending, + editingCommentId, + initialContent = "", + onCancel, +}: CommentFormProps) { + const [comment, setComment] = useState(initialContent); const handleSubmit = () => { if (!comment.trim()) return; @@ -16,6 +25,11 @@ export function CommentForm({ onSubmit, isPending }: CommentFormProps) { setComment(""); }; + const handleCancel = () => { + setComment(""); + onCancel?.(); + }; + const handleKeyPress = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); @@ -26,19 +40,29 @@ export function CommentForm({ onSubmit, isPending }: CommentFormProps) { return (
setComment(e.target.value)} onKeyPress={handleKeyPress} className="flex-1 bg-brand-surface border-none rounded-md px-4 py-2 resize-none no-scrollbar text-sm text-white ring-0" disabled={isPending} /> + {editingCommentId && onCancel && ( + + )}
); diff --git a/src/pages/community/__components/CommentItem.tsx b/src/pages/community/__components/CommentItem.tsx index 1078cee..04126e4 100644 --- a/src/pages/community/__components/CommentItem.tsx +++ b/src/pages/community/__components/CommentItem.tsx @@ -1,11 +1,25 @@ import type { CommentResponse } from "@/types/post.types"; +import { Button } from "@/components/ui/button"; +import { MoreHorizontal } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { formatCommentDate } from "@/lib/date"; interface CommentItemProps { comment: CommentResponse; + onEditClick?: () => void; + onDeleteClick?: () => void; } -export function CommentItem({ comment }: CommentItemProps) { +export function CommentItem({ + comment, + onEditClick, + onDeleteClick, +}: CommentItemProps) { return (

@@ -18,6 +32,31 @@ export function CommentItem({ comment }: CommentItemProps) { {formatCommentDate(comment.createdAt)}

+ + {comment.editable && ( + + + + + + + 수정 + + + 삭제 + + + + )}
); diff --git a/src/pages/community/__components/CommentSection.tsx b/src/pages/community/__components/CommentSection.tsx index e9702b0..2cf3fcc 100644 --- a/src/pages/community/__components/CommentSection.tsx +++ b/src/pages/community/__components/CommentSection.tsx @@ -3,9 +3,15 @@ import { CommentItem } from "./CommentItem"; interface CommentSectionProps { comments: CommentResponse[]; + onEditComment?: (commentId: number) => void; + onDeleteComment?: (commentId: number) => void; } -export function CommentSection({ comments }: CommentSectionProps) { +export function CommentSection({ + comments, + onEditComment, + onDeleteComment, +}: CommentSectionProps) { const totalComments = comments.length; return ( @@ -17,7 +23,12 @@ export function CommentSection({ comments }: CommentSectionProps) { .slice() .reverse() .map((comment) => ( - + onEditComment?.(comment.id)} + onDeleteClick={() => onDeleteComment?.(comment.id)} + /> ))}
diff --git a/src/pages/community/__components/PostContent.tsx b/src/pages/community/__components/PostContent.tsx index c27ffe8..bcbffd6 100644 --- a/src/pages/community/__components/PostContent.tsx +++ b/src/pages/community/__components/PostContent.tsx @@ -1,16 +1,29 @@ -import type { PostDetailResponse } from "@/types/post.types"; +import type { PostDetailItem } from "@/types/post.types"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; import { Button } from "@/components/ui/button"; -import { Heart } from "lucide-react"; +import { Heart, MoreHorizontal } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; interface PostContentProps { - post: PostDetailResponse; + post: PostDetailItem; onLikeClick: () => void; isLikePending: boolean; + onEditClick?: () => void; + onDeleteClick?: () => void; } -export function PostContent({ post, onLikeClick }: PostContentProps) { +export function PostContent({ + post, + onLikeClick, + onEditClick, + onDeleteClick, +}: PostContentProps) { return (
@@ -33,22 +46,49 @@ export function PostContent({ post, onLikeClick }: PostContentProps) {
{post.content}
-
- +
+
+ - - {post.nLikes} - + + {post.nLikes} + +
+ + {post.editable && ( + + + + + + + 수정 + + + 삭제 + + + + )}
diff --git a/src/pages/community/index.tsx b/src/pages/community/index.tsx index 0a654cd..ed40976 100644 --- a/src/pages/community/index.tsx +++ b/src/pages/community/index.tsx @@ -1,23 +1,52 @@ import { useParams, useNavigate } from "react-router-dom"; +import { useState } from "react"; import Header from "@/components/layout/Header"; -import { ArrowLeft, Bookmark, Heart, MessageCircle } from "lucide-react"; +import { + ArrowLeft, + Bookmark, + Heart, + MessageCircle, + Users, + Trash2, +} from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; import { Button } from "@/components/ui/button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { usePostDetail, useComments, useToggleLike, useToggleBookmark, useCreateComment, + useApplicants, + useToggleApply, + useDeletePost, + useUpdateComment, + useDeleteComment, } from "@/hooks/queries/usePosts"; import { CommentForm } from "./__components/CommentForm"; import { CommentSection } from "./__components/CommentSection"; +import { ApplicantSection } from "./__components/ApplicantSection"; + import { formatPostDetailDate } from "@/lib/date"; export function PostDetailPage() { const { postId } = useParams<{ postId: string }>(); const navigate = useNavigate(); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [showCommentDeleteDialog, setShowCommentDeleteDialog] = useState(false); + const [commentToDelete, setCommentToDelete] = useState(null); + const [editingCommentId, setEditingCommentId] = useState(null); if (!postId) { return
유효하지 않은 게시글 ID입니다.
; @@ -30,9 +59,19 @@ export function PostDetailPage() { isError: isCommentsError, } = useComments(postId); + const { + data: applicants = [], + isLoading: isApplicantsLoading, + isError: isApplicantsError, + } = useApplicants(postId); + const toggleLikeMutation = useToggleLike(); const toggleBookmarkMutation = useToggleBookmark(); const createCommentMutation = useCreateComment(); + const toggleApplyMutation = useToggleApply(); + const deletePostMutation = useDeletePost(); + const updateCommentMutation = useUpdateComment(); + const deleteCommentMutation = useDeleteComment(); if (isLoading) { return
로딩 중...
; @@ -76,20 +115,87 @@ export function PostDetailPage() { } }; + const handleApplyClick = async () => { + if (!postId) return; + try { + await toggleApplyMutation.mutateAsync(parseInt(postId)); + } catch (error) { + console.error("지원 처리 실패:", error); + alert("지원 처리에 실패했습니다. 다시 시도해주세요."); + } + }; + + const handleDeletePost = async () => { + if (!post) return; + try { + await deletePostMutation.mutateAsync(post.id); + setShowDeleteDialog(false); + navigate(-1); + } catch (error) { + console.error("게시글 삭제 실패:", error); + alert("게시글 삭제에 실패했습니다. 다시 시도해주세요."); + } + }; + + const handleEditComment = (commentId: number) => { + setEditingCommentId(commentId); + }; + + const handleCommentEditSubmit = async (content: string) => { + if (!editingCommentId) return; + try { + await updateCommentMutation.mutateAsync({ + commentId: editingCommentId, + content, + }); + setEditingCommentId(null); + } catch (error) { + console.error("댓글 수정 실패:", error); + alert("댓글 수정에 실패했습니다. 다시 시도해주세요."); + } + }; + + const handleCommentEditCancel = () => { + setEditingCommentId(null); + }; + + const handleDeleteComment = async () => { + if (!commentToDelete) return; + try { + await deleteCommentMutation.mutateAsync(commentToDelete); + setShowCommentDeleteDialog(false); + setCommentToDelete(null); + } catch (error) { + console.error("댓글 삭제 실패:", error); + alert("댓글 삭제에 실패했습니다. 다시 시도해주세요."); + } + }; + + const openCommentDeleteDialog = (commentId: number) => { + setCommentToDelete(commentId); + setShowCommentDeleteDialog(true); + }; + return (
} rightIcon={ - + post.editable ? ( + + ) : ( + + ) } onLeftClick={handleBackClick} - onRightClick={handleBookmarkClick} + onRightClick={ + post.editable ? () => setShowDeleteDialog(true) : handleBookmarkClick + } />
@@ -118,48 +224,168 @@ export function PostDetailPage() { )}
-
- -
- - {post.nComments} +
+
+ + {/* 일반 게시판만 댓글 개수 표시 */} + {!post.board.isRecruit && ( +
+ + {post.nComments} +
+ )} + {/* 모집 게시판은 지원자 개수 표시 */} + {post.board.isRecruit && ( +
+ + {post.nApplicants || applicants.length} +
+ )}
- {isCommentsLoading ? ( -
- 댓글을 불러오는 중... -
- ) : isCommentsError ? ( -
- 댓글을 불러오는 데 실패했습니다. -
- ) : ( - + {/* 일반 게시판: 댓글 표시 */} + {!post.board.isRecruit && ( + <> + {isCommentsLoading ? ( +
+ 댓글을 불러오는 중... +
+ ) : isCommentsError ? ( +
+ 댓글을 불러오는 데 실패했습니다. +
+ ) : ( + + )} + + )} + + {/* 모집 게시판: 지원자 목록 표시 */} + {post.board.isRecruit && ( + <> + {isApplicantsLoading ? ( +
+ 지원자 목록을 불러오는 중... +
+ ) : isApplicantsError ? ( +
+ 지원자 목록을 불러오는 데 실패했습니다. +
+ ) : ( + + )} + )}
-
- -
+ {/* 자유, 질문 게시판만 댓글 작성 가능 */} + {!post.board.isRecruit && ( +
+ c.id === editingCommentId)?.content || "" + : "" + } + onCancel={handleCommentEditCancel} + /> +
+ )} + + {/* 모각코, 프로젝트 게시판은 지원 버튼 표시 */} + {post.board.isRecruit && ( +
+ +
+ )} + + + + + 게시글 삭제 + + 이 게시글을 삭제하시겠습니까?

이 작업은 되돌릴 수 + 없습니다. +
+
+ + + 취소 + + + 삭제 + + +
+
+ + {/* 댓글 삭제 확인 다이얼로그 */} + + + + 댓글 삭제 + + 이 댓글을 삭제하시겠습니까?

이 작업은 되돌릴 수 없습니다. +
+
+ + + 취소 + + + 삭제 + + +
+
); } diff --git a/src/types/post.types.ts b/src/types/post.types.ts index cb01ea7..aa8811e 100644 --- a/src/types/post.types.ts +++ b/src/types/post.types.ts @@ -36,6 +36,15 @@ export interface PostDetailResponse { bookmark: boolean; } +export interface PostListItem extends PostListResponse { + nApplicants?: number; +} + +export interface PostDetailItem extends PostDetailResponse { + nApplicants?: number; + applied?: boolean; +} + export interface CommentResponse { id: number; postId: number; @@ -61,12 +70,10 @@ export interface PostCommentResponse { nComments: number; } -// ===== Request Types ===== - -// 게시글 생성 요청 export interface CreatePostRequest { title: string; content: string; + boardId: number; location: { lng: number; lat: number; @@ -76,3 +83,21 @@ export interface CreatePostRequest { export interface CreateCommentRequest { content: string; } + +export interface UpdatePostRequest { + title: string; + content: string; +} + +export interface UpdateCommentRequest { + content: string; +} + +export interface ApplicantResponse { + nickname: string; + avatar: string; +} + +export interface ApplyToggleResponse { + applied: boolean; +} From 1e9a9e3d6aed562feba4da6b71f99d8ce903f01d Mon Sep 17 00:00:00 2001 From: Monixc Date: Mon, 11 Aug 2025 04:41:16 +0900 Subject: [PATCH 13/26] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20=EB=B0=8F=20=EB=AA=A9=EB=A1=9D=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 위치 정보 가져오기 기능 추가 및 관련 에러 처리 구현 - 게시글 작성 시 위치 정보 필드 추가 - ListItem 컴포넌트에서 모집 게시판에 지원자 수 표시 기능 추가 - AddPostModal에서 위치 정보 자동 설정 및 초기 데이터 처리 로직 개선 - RecruitItem에서 포지션 선택 기능을 프로젝트 게시판에 맞게 수정 --- src/pages/home/_components/ListItem.tsx | 24 +++- .../home/_components/modal/AddPostModal.tsx | 120 ++++++++++++++++-- .../home/_components/modal/RecruitItem.tsx | 22 +++- src/pages/home/index.tsx | 4 +- 4 files changed, 143 insertions(+), 27 deletions(-) diff --git a/src/pages/home/_components/ListItem.tsx b/src/pages/home/_components/ListItem.tsx index 02a54d5..96251bf 100644 --- a/src/pages/home/_components/ListItem.tsx +++ b/src/pages/home/_components/ListItem.tsx @@ -1,12 +1,12 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; -import { Heart, MessageCircle } from "lucide-react"; +import { Heart, MessageCircle, Users } from "lucide-react"; import { useNavigate } from "react-router-dom"; -import type { Post } from "@/types/post.types"; +import type { PostListItem } from "@/types/post.types"; import { formatPostListDate } from "@/lib/date"; interface ListItemProps { - post: Post; + post: PostListItem; } export const ListItem = ({ post }: ListItemProps) => { @@ -35,10 +35,20 @@ export const ListItem = ({ post }: ListItemProps) => { {post.nLikes}
-
- - {post.nComments} -
+ {/* 일반 게시판: 댓글 개수 표시 */} + {!post.board.isRecruit && ( +
+ + {post.nComments} +
+ )} + {/* 모집 게시판: 지원자 개수 표시. api에 관련 필드 없는 관계로 쿼리로 처리 중--개선 필요*/} + {post.board.isRecruit && ( +
+ + {post.nApplicants || 0} +
+ )} diff --git a/src/pages/home/_components/modal/AddPostModal.tsx b/src/pages/home/_components/modal/AddPostModal.tsx index 4e9f6b0..ee59447 100644 --- a/src/pages/home/_components/modal/AddPostModal.tsx +++ b/src/pages/home/_components/modal/AddPostModal.tsx @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import { Button } from "@/components/ui/button"; import { Select, @@ -12,17 +13,33 @@ import { PostItem } from "./PostItem"; import { RecruitItem } from "./RecruitItem"; import { useAddPostModal } from "../../../../hooks/useAddPostModal"; import { useBoards } from "../../../../hooks/queries/usePosts"; +import { + usePositionsInterests, + useStacks, +} from "../../../../hooks/queries/useTags"; +import { useLocationQuery } from "../../../../hooks/queries/useLocationQuery"; interface AddPostModalProps { onClose: () => void; - onSubmit: (data: { title: string; content: string; boardId: number }) => void; + onSubmit: (data: { + title: string; + content: string; + boardId: number; + location: { lat: number; lng: number }; + }) => void; isSubmitting?: boolean; + initialData?: { + title: string; + content: string; + boardId: number; + }; } export const AddPostModal = ({ onClose, onSubmit, isSubmitting = false, + initialData, }: AddPostModalProps) => { const { title, @@ -31,7 +48,9 @@ export const AddPostModal = ({ description, recruitCount, position, + selectedPositionIds, selectedStackIds, + location, isRecruitBoard, setTitle, setContent, @@ -39,11 +58,51 @@ export const AddPostModal = ({ setDescription, setRecruitCount, setPosition, + setSelectedPositionIds, setSelectedStackIds, + setLocation, reset, } = useAddPostModal(); const { data: boards = [] } = useBoards(); + const { data: positionsInterestsData } = usePositionsInterests(); + const { data: stacks } = useStacks(); + const { location: currentLocation, refetchLocation } = useLocationQuery(); + + useEffect(() => { + if (currentLocation && !location) { + setLocation(currentLocation); + } + }, [currentLocation, location, setLocation]); + + useEffect(() => { + if (!location && currentLocation) { + setLocation(currentLocation); + } + }, [location, currentLocation, setLocation]); + + useEffect(() => { + if (initialData && !location) { + if (currentLocation) { + setLocation(currentLocation); + } else { + refetchLocation().then(() => { + console.log("위치 정보 다시 가져오기 완료"); + }); + } + } + }, [initialData, currentLocation, location, setLocation, refetchLocation]); + + useEffect(() => { + if (initialData) { + setTitle(initialData.title); + setContent(initialData.content); + const board = boards.find((b) => b.id === initialData.boardId); + if (board) { + setSelectedBoard(board.value); + } + } + }, [initialData, boards]); const handleSubmit = () => { if (!title.trim() || !selectedBoard) { @@ -51,7 +110,10 @@ export const AddPostModal = ({ } if (isRecruitBoard) { - if (!description.trim() || !position || selectedStackIds.length === 0) { + if (!description.trim() || selectedStackIds.length === 0) { + return; + } + if (selectedBoard === "프로젝트" && selectedPositionIds.length === 0) { return; } } else { @@ -60,15 +122,41 @@ export const AddPostModal = ({ } } + if (!location) { + alert("위치 정보가 필요합니다. 위치 정보를 설정해주세요."); + return; + } + const selectedBoardData = boards.find( (board) => board.value === selectedBoard ); if (!selectedBoardData) return; + let finalContent = isRecruitBoard ? description.trim() : content.trim(); + + if (isRecruitBoard) { + finalContent += `\n\n모집 인원: ${recruitCount}명`; + if (selectedBoard === "프로젝트" && selectedPositionIds.length > 0) { + const positions = positionsInterestsData?.positions || []; + const selectedPositions = positions.filter((pos) => + selectedPositionIds.includes(pos.id) + ); + const positionText = selectedPositions + .map((pos) => pos.role) + .join(", "); + finalContent += `\n포지션: ${positionText}`; + } + const selectedStacks = + stacks?.filter((stack) => selectedStackIds.includes(stack.id)) || []; + const stackText = selectedStacks.map((stack) => stack.value).join(", "); + finalContent += `\n기술스택: ${stackText}`; + } + const postData = { title: title.trim(), - content: isRecruitBoard ? description.trim() : content.trim(), + content: finalContent, boardId: selectedBoardData.id, + location, }; onSubmit(postData); @@ -79,6 +167,17 @@ export const AddPostModal = ({ onClose(); }; + const isDisabled = + isSubmitting || + !title.trim() || + !selectedBoard || + (isRecruitBoard + ? !description.trim() || + selectedStackIds.length === 0 || + (selectedBoard === "프로젝트" && selectedPositionIds.length === 0) + : !content.trim()) || + !location; + const footer = (
@@ -106,7 +198,6 @@ export const AddPostModal = ({ return (
- {/* 게시판 선택 */}
- - {/*포스트, 리크루트 조건부 렌더링*/} {isRecruitBoard ? ( ) : ( diff --git a/src/pages/home/_components/modal/RecruitItem.tsx b/src/pages/home/_components/modal/RecruitItem.tsx index 1948750..3d2be50 100644 --- a/src/pages/home/_components/modal/RecruitItem.tsx +++ b/src/pages/home/_components/modal/RecruitItem.tsx @@ -17,11 +17,14 @@ interface RecruitItemProps { description: string; recruitCount: number; position: number | null; + selectedPositionIds: number[]; selectedStackIds: number[]; + boardValue: string; onTitleChange: (title: string) => void; onDescriptionChange: (description: string) => void; onRecruitCountChange: (count: number) => void; onPositionChange: (position: number | null) => void; + onPositionIdsChange: (positionIds: number[]) => void; onStackIdsChange: (stackIds: number[]) => void; } @@ -30,11 +33,14 @@ export const RecruitItem = ({ description, recruitCount, position, + selectedPositionIds, selectedStackIds, + boardValue, onTitleChange, onDescriptionChange, onRecruitCountChange, onPositionChange, + onPositionIdsChange, onStackIdsChange, }: RecruitItemProps) => { const [isRecruitPopoverOpen, setIsRecruitPopoverOpen] = useState(false); @@ -111,11 +117,17 @@ export const RecruitItem = ({
- + {/* 모각코에서는 포지션 제외, 프로젝트에서는 포지션 다중 선택 가능 */} + {boardValue === "프로젝트" && ( + + )} { const [isModalOpen, setIsModalOpen] = useState(false); const { data: boards = [], isLoading: boardsLoading } = useBoards(); + const { refetchLocation } = useLocationQuery(); const validCategories = [ "all", @@ -58,12 +60,12 @@ export const BoardPage = () => { title: string; content: string; boardId: number; + location: { lat: number; lng: number }; }) => { try { await createPostMutation.mutateAsync(data); setIsModalOpen(false); } catch (err) { - console.error("게시글 작성 실패:", err); alert("게시글 작성에 실패했습니다."); } }; From 7941b0ff6ec9ed89c79e2b2bd9933a72eaa6fd6d Mon Sep 17 00:00:00 2001 From: Monixc Date: Mon, 11 Aug 2025 04:41:59 +0900 Subject: [PATCH 14/26] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=20=ED=95=B8=EB=93=A4=EB=9F=AC=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SearchResults 컴포넌트에서 게시글 타입을 Post에서 PostListItem으로 변경 - 클릭 핸들러에서 사용되는 게시글 타입을 Post에서 PostListItem으로 수정 --- src/pages/search/_components/SearchResults.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/search/_components/SearchResults.tsx b/src/pages/search/_components/SearchResults.tsx index 33606e5..3f1f18c 100644 --- a/src/pages/search/_components/SearchResults.tsx +++ b/src/pages/search/_components/SearchResults.tsx @@ -2,11 +2,11 @@ import { useNavigate } from "react-router-dom"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Heart, MessageCircle } from "lucide-react"; -import type { Post } from "@/types/post.types"; +import type { PostListItem } from "@/types/post.types"; import { formatPostListDate } from "@/lib/date"; interface SearchResultsProps { - posts: Post[]; + posts: PostListItem[]; isLoading: boolean; isError: boolean; error: Error | null; @@ -24,7 +24,7 @@ export default function SearchResults({ }: SearchResultsProps) { const navigate = useNavigate(); - const handlePostClick = (post: Post) => { + const handlePostClick = (post: PostListItem) => { navigate(`/community/${post.id}`); }; From 5de87f53b6efad3e6d9a04b4c5dcebc51cb4a866 Mon Sep 17 00:00:00 2001 From: Monixc Date: Mon, 11 Aug 2025 04:52:24 +0900 Subject: [PATCH 15/26] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=EA=B8=B0=EB=8A=A5=20=EB=B0=8F=20API=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 프로필 수정 페이지(EditProfilePage) 추가 및 관련 UI 구현 - 프로필 정보를 가져오는 훅(useMyProfile) 및 프로필 업데이트 훅(useUpdateProfile) 추가 - 프로필 섹션(ProfileSection)에서 프로필 정보 표시 및 수정 버튼 기능 추가 - API 호출을 통해 프로필 정보를 가져오고 수정하는 기능 구현 --- src/components/profile/ProfileSection.tsx | 80 +++--- src/hooks/queries/useMy.ts | 21 ++ src/lib/api/my.ts | 42 +++ src/main.tsx | 5 + src/pages/my/edit/index.tsx | 302 ++++++++++++++++++++++ src/pages/my/index.tsx | 43 ++- 6 files changed, 440 insertions(+), 53 deletions(-) create mode 100644 src/hooks/queries/useMy.ts create mode 100644 src/lib/api/my.ts create mode 100644 src/pages/my/edit/index.tsx diff --git a/src/components/profile/ProfileSection.tsx b/src/components/profile/ProfileSection.tsx index c380fcd..7d7cc94 100644 --- a/src/components/profile/ProfileSection.tsx +++ b/src/components/profile/ProfileSection.tsx @@ -1,71 +1,49 @@ -import { useRef, useState } from "react"; import { Button } from "@/components/ui/button"; -import { Camera } from "lucide-react"; +import type { GetProfileResponse } from "@/types/my.types"; interface ProfileSectionProps { + profile?: GetProfileResponse; isEditable?: boolean; + onEditClick?: () => void; } -export const ProfileSection = ({ isEditable = false }: ProfileSectionProps) => { - const fileInputRef = useRef(null); - const [imagePreview, setImagePreview] = useState(null); - - const handleImageClick = () => { - if (!isEditable) return; - fileInputRef.current?.click(); - }; - - const handleFileChange = (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (file) { - const reader = new FileReader(); - - reader.onloadend = () => { - if (typeof reader.result === "string") { - setImagePreview(reader.result); - } - }; - reader.readAsDataURL(file); - } - }; +export const ProfileSection = ({ + profile, + onEditClick, +}: ProfileSectionProps) => { + const displayImage = profile?.avatar; + const displayName = profile?.nickname || "이름"; + const displayInfo = profile + ? `${profile.age}세 · ${profile.position?.role || "포지션"}` + : "나이 · 포지션"; return (
-
- {imagePreview ? ( +
+ {displayImage ? ( Profile preview ) : ( - isEditable && +
+ + {displayName.charAt(0)} + +
)}
- {isEditable && ( - - )}
-

이름

-

나이 · 포지션

- {isEditable && ( - - )} +

{displayName}

+

{displayInfo}

+
); }; diff --git a/src/hooks/queries/useMy.ts b/src/hooks/queries/useMy.ts new file mode 100644 index 0000000..5ec6f18 --- /dev/null +++ b/src/hooks/queries/useMy.ts @@ -0,0 +1,21 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { fetchMyProfile, updateMyProfile } from "@/lib/api/my"; +import type { UpdateProfileRequest } from "@/types/my.types"; + +export const useMyProfile = () => { + return useQuery({ + queryKey: ["myProfile"], + queryFn: fetchMyProfile, + }); +}; + +export const useUpdateProfile = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: UpdateProfileRequest) => updateMyProfile(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["myProfile"] }); + }, + }); +}; diff --git a/src/lib/api/my.ts b/src/lib/api/my.ts new file mode 100644 index 0000000..175a898 --- /dev/null +++ b/src/lib/api/my.ts @@ -0,0 +1,42 @@ +import type { + GetProfileResponse, + UpdateProfileRequest, +} from "@/types/my.types"; +import { createApiUrl, createAuthHeaders } from "./config"; + +export const fetchMyProfile = async (): Promise => { + const url = `${createApiUrl("/users")}`; + const headers = createAuthHeaders(); + const response = await fetch(url, { + headers, + }); + + if (!response.ok) { + throw new Error( + `프로필을 불러오는 데 실패했습니다: ${response.status} ${response.statusText}` + ); + } + const data = await response.json(); + return data; +}; + +export const updateMyProfile = async ( + data: UpdateProfileRequest +): Promise => { + const url = `${createApiUrl("/users")}`; + const headers = createAuthHeaders(); + const response = await fetch(url, { + method: "PATCH", + headers: { + ...headers, + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + throw new Error( + `프로필 수정에 실패했습니다: ${response.status} ${response.statusText}` + ); + } +}; diff --git a/src/main.tsx b/src/main.tsx index 1b43f71..14fa1b7 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,6 +7,7 @@ import App from "./App.tsx"; import { LoginPage } from "./pages/auth/LoginPage.tsx"; import { BoardPage } from "./pages/home/index.tsx"; import { MyPage } from "./pages/my/index.tsx"; +import { EditProfilePage } from "./pages/my/edit/index.tsx"; import { DeveloperPage } from "./pages/developer/index.tsx"; import { ExplorePage } from "./pages/explore/index.tsx"; import { OnboardingPage } from "./pages/onboarding/index.tsx"; @@ -57,6 +58,10 @@ const router = createBrowserRouter([ path: "my", element: , }, + { + path: "my/edit", + element: , + }, { path: "developer/:id", element: , diff --git a/src/pages/my/edit/index.tsx b/src/pages/my/edit/index.tsx new file mode 100644 index 0000000..4c060a4 --- /dev/null +++ b/src/pages/my/edit/index.tsx @@ -0,0 +1,302 @@ +import { useState, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { + ArrowLeft, + Save, + Linkedin, + Mail, + Instagram, + Rss, + Github, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { PositionSelector } from "@/components/common/PositionSelector"; +import { StackSelector } from "@/components/common/StackSelector"; +import Header from "@/components/layout/Header"; +import { useMyProfile, useUpdateProfile } from "@/hooks/queries/useMy"; +import { usePositionsInterests } from "@/hooks/queries/useTags"; +import { cn } from "@/lib/utils"; +import type { UpdateProfileRequest } from "@/types/my.types"; + +export const EditProfilePage = () => { + const navigate = useNavigate(); + const { data: profile, isLoading } = useMyProfile(); + const { data: positionsInterestsData } = usePositionsInterests(); + const updateProfileMutation = useUpdateProfile(); + + const [formData, setFormData] = useState({ + nickname: "", + experience: 0, + bio: "", + positionId: undefined, + techIds: [], + interestIds: [], + github: "", + email: "", + instagram: "", + linkedIn: "", + blog: "", + }); + + useEffect(() => { + if (profile) { + setFormData({ + nickname: profile.nickname || "", + experience: profile.experience || 0, + bio: profile.bio || "", + positionId: profile.position?.id || undefined, + techIds: profile.techStack?.map((tech: any) => tech.id) || [], + interestIds: + profile.interests?.map((interest: any) => interest.id) || [], + github: profile.github || "", + email: profile.email || "", + instagram: profile.instagram || "", + linkedIn: profile.linkedIn || "", + blog: profile.blog || "", + }); + } + }, [profile]); + + const handleInputChange = (field: keyof UpdateProfileRequest, value: any) => { + setFormData((prev) => ({ + ...prev, + [field]: value, + })); + }; + + const handleSubmit = async () => { + try { + const requestData = { + nickname: formData.nickname, + experience: formData.experience, + bio: formData.bio, + positionId: formData.positionId, + techIds: formData.techIds, + interestIds: formData.interestIds, + ...(formData.github && { github: formData.github }), + ...(formData.email && { email: formData.email }), + ...(formData.instagram && { instagram: formData.instagram }), + ...(formData.linkedIn && { linkedIn: formData.linkedIn }), + ...(formData.blog && { blog: formData.blog }), + }; + + await updateProfileMutation.mutateAsync(requestData); + navigate("/my"); + } catch (error) { + alert("프로필 수정에 실패했습니다. 다시 시도해주세요."); + } + }; + + const handleBack = () => { + navigate("/my"); + }; + + if (isLoading) { + return ( +
+
+
+

프로필을 불러오는 중...

+
+
+ ); + } + + return ( +
+
} + onLeftClick={handleBack} + /> + +
+ {/* 기본 정보 */} +
+

기본 정보

+ +
+ + handleInputChange("nickname", e.target.value)} + className="bg-brand-surface border-brand-surface text-white" + placeholder="닉네임을 입력하세요" + /> +
+ +
+ + + handleInputChange("experience", parseInt(e.target.value) || 0) + } + className="bg-brand-surface border-brand-surface text-white" + placeholder="경력을 입력하세요" + /> +
+ +
+ +