From e7d48fcd1b65784d1c1b9874cb5d5d63407415ac Mon Sep 17 00:00:00 2001 From: sooloin Date: Sat, 13 Jun 2026 20:47:06 +0900 Subject: [PATCH 1/4] =?UTF-8?q?:sparkles:=20Feat:=20=EC=BB=A4=EB=AE=A4?= =?UTF-8?q?=EB=8B=88=ED=8B=B0=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=EC=97=90=20=EB=8C=93=EA=B8=80=20CRUD=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 댓글 목록 조회 API 연동 및 페이지네이션 추가 - 게시글 상세에서 댓글 작성/수정/삭제 기능 구현 - 대댓글 작성 UI 및 인라인 댓글 수정 흐름 추가 - 댓글 API 타입, react-query 훅, 쿼리 키 구성 추가 - 댓글 조회/변경 실패 메시지 및 상태 처리 정리 `#54` --- src/features/community/api/comments.ts | 33 ++ src/features/community/index.ts | 15 + src/features/community/lib/constants.ts | 47 +- src/features/community/model/types.ts | 59 ++ .../community/model/useCommentList.ts | 14 + .../community/model/useCreateComment.ts | 20 + .../community/model/useDeleteComment.ts | 25 + .../community/model/useUpdateComment.ts | 23 + .../community/ui/BoardDetailContent.tsx | 545 ++++++++++++++++-- src/pages/community/BoardDetailPage.tsx | 95 +++ src/shared/lib/react-query/queryKey.ts | 2 + 11 files changed, 805 insertions(+), 73 deletions(-) create mode 100644 src/features/community/api/comments.ts create mode 100644 src/features/community/model/useCommentList.ts create mode 100644 src/features/community/model/useCreateComment.ts create mode 100644 src/features/community/model/useDeleteComment.ts create mode 100644 src/features/community/model/useUpdateComment.ts diff --git a/src/features/community/api/comments.ts b/src/features/community/api/comments.ts new file mode 100644 index 0000000..6bc170c --- /dev/null +++ b/src/features/community/api/comments.ts @@ -0,0 +1,33 @@ +import { apiClient } from '@/shared/api/axios'; + +import type { + CommentListResponse, + CreateCommentRequest, + CreateCommentResponse, + DeleteCommentResponse, + UpdateCommentRequest, + UpdateCommentResponse, +} from '../model/types'; + +export async function getCommentList( + boardId: number, + params: { page: number; size: number }, +): Promise { + const response = await apiClient.get(`/comments/${boardId}`, { params }); + return response.data; +} + +export async function createComment(payload: CreateCommentRequest): Promise { + const response = await apiClient.post('/comments', payload); + return response.data; +} + +export async function updateComment(commentId: number, payload: UpdateCommentRequest): Promise { + const response = await apiClient.patch(`/comments/${commentId}`, payload); + return response.data; +} + +export async function deleteComment(commentId: number): Promise { + const response = await apiClient.delete(`/comments/${commentId}`); + return response.data; +} diff --git a/src/features/community/index.ts b/src/features/community/index.ts index 56ac648..c2e7a90 100644 --- a/src/features/community/index.ts +++ b/src/features/community/index.ts @@ -7,11 +7,14 @@ export { tempSaveBoard, updateBoard, } from './api/boards'; +export { createComment, deleteComment, getCommentList, updateComment } from './api/comments'; export { BOARD_DETAIL_STATUS_MESSAGES, BOARD_DRAFT_STATUS_MESSAGES, BOARD_LIST_STATUS_MESSAGES, BOARD_MUTATION_STATUS_MESSAGES, + COMMENT_LIST_STATUS_MESSAGES, + COMMENT_MUTATION_STATUS_MESSAGES, COMMUNITY_DRAFT_SESSION_KEY, } from './lib/constants'; export { @@ -23,10 +26,14 @@ export { INITIAL_BOARD_EDITOR_FORM_STATE, validateBoardEditorForm } from './lib/ export { useBoardDetail } from './model/useBoardDetail'; export { useBoardEditorForm } from './model/useBoardEditorForm'; export { useBoardList } from './model/useBoardList'; +export { useCommentList } from './model/useCommentList'; export { useCreateBoard } from './model/useCreateBoard'; +export { useCreateComment } from './model/useCreateComment'; export { useDeleteBoard } from './model/useDeleteBoard'; +export { useDeleteComment } from './model/useDeleteComment'; export { useTempSaveBoard } from './model/useTempSaveBoard'; export { useTempSavedBoard } from './model/useTempSavedBoard'; +export { useUpdateComment } from './model/useUpdateComment'; export { useUpdateBoard } from './model/useUpdateBoard'; export { CommunityFeedCard } from './ui/CommunityFeedCard'; export { CommunityLayout } from './ui/CommunityLayout'; @@ -36,16 +43,24 @@ export { BoardDetailContent } from './ui/BoardDetailContent'; export { BoardEditorForm } from './ui/BoardEditorForm'; export { DeleteBoardDialog } from './ui/DeleteBoardDialog'; export type { + BoardComment, BoardDetailResponse, BoardListItem, BoardListResponse, BoardPayload, + CommentListResponse, + CommentPageInfo, + CreateCommentRequest, + CreateCommentResponse, CreateBoardRequest, CreateBoardResponse, + DeleteCommentResponse, DeleteBoardResponse, TempSavedBoardResponse, TempSaveBoardRequest, TempSaveBoardResponse, + UpdateCommentRequest, + UpdateCommentResponse, UpdateBoardRequest, UpdateBoardResponse, } from './model/types'; diff --git a/src/features/community/lib/constants.ts b/src/features/community/lib/constants.ts index b9116dd..930e4bb 100644 --- a/src/features/community/lib/constants.ts +++ b/src/features/community/lib/constants.ts @@ -1,30 +1,45 @@ export const COMMUNITY_DRAFT_SESSION_KEY = 'community-board-draft-session-key'; export const BOARD_LIST_STATUS_MESSAGES: Partial> = { - 400: '잘못된 요청입니다.', - 401: '로그인이 필요합니다. 다시 로그인해주세요.', - 500: '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', + 400: '잘못된 요청이에요.', + 401: '로그인이 필요해요. 다시 로그인해주세요.', + 500: '서버 오류가 발생했어요. 잠시 후 다시 시도해주세요.', }; export const BOARD_MUTATION_STATUS_MESSAGES: Partial> = { 400: '입력값을 다시 확인해주세요.', - 401: '로그인이 필요합니다. 다시 로그인해주세요.', - 403: '게시글을 처리할 권한이 없습니다.', - 500: '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', + 401: '로그인이 필요해요. 다시 로그인해주세요.', + 403: '게시글을 처리할 권한이 없어요.', + 500: '서버 오류가 발생했어요. 잠시 후 다시 시도해주세요.', }; export const BOARD_DETAIL_STATUS_MESSAGES: Partial> = { - 400: '잘못된 게시글 요청입니다.', - 401: '로그인이 필요합니다. 다시 로그인해주세요.', - 403: '게시글을 조회할 권한이 없습니다.', - 404: '게시글을 찾을 수 없습니다.', - 500: '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', + 400: '잘못된 게시글 요청이에요.', + 401: '로그인이 필요해요. 다시 로그인해주세요.', + 403: '게시글을 조회할 권한이 없어요.', + 404: '게시글을 찾을 수 없어요.', + 500: '서버 오류가 발생했어요. 잠시 후 다시 시도해주세요.', }; export const BOARD_DRAFT_STATUS_MESSAGES: Partial> = { - 400: '임시 저장 요청을 처리할 수 없습니다.', - 401: '로그인이 필요합니다. 다시 로그인해주세요.', - 403: '임시 저장 게시글을 처리할 권한이 없습니다.', - 404: '임시 저장된 게시글을 찾을 수 없습니다.', - 500: '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', + 400: '임시 저장 요청을 처리할 수 없어요.', + 401: '로그인이 필요해요. 다시 로그인해주세요.', + 403: '임시 저장 게시글을 처리할 권한이 없어요.', + 404: '임시 저장한 게시글을 찾을 수 없어요.', + 500: '서버 오류가 발생했어요. 잠시 후 다시 시도해주세요.', +}; + +export const COMMENT_LIST_STATUS_MESSAGES: Partial> = { + 400: '댓글 조회 요청이 올바르지 않아요.', + 401: '로그인이 필요해요. 다시 로그인해주세요.', + 404: '해당 게시글을 찾을 수 없어요.', + 500: '서버 오류가 발생했어요. 잠시 후 다시 시도해주세요.', +}; + +export const COMMENT_MUTATION_STATUS_MESSAGES: Partial> = { + 400: '입력한 댓글 내용을 다시 확인해주세요.', + 401: '로그인이 필요해요. 다시 로그인해주세요.', + 403: '댓글을 처리할 권한이 없어요.', + 404: '대상 댓글 또는 게시글을 찾을 수 없어요.', + 500: '서버 오류가 발생했어요. 잠시 후 다시 시도해주세요.', }; diff --git a/src/features/community/model/types.ts b/src/features/community/model/types.ts index 9eeb58b..948d452 100644 --- a/src/features/community/model/types.ts +++ b/src/features/community/model/types.ts @@ -75,3 +75,62 @@ export interface UpdateBoardResponse { export interface DeleteBoardResponse { message: string; } + +export interface CommentAuthor { + userId?: string; + nickname: string; + profileUrl?: string | null; +} + +export interface BoardComment { + commentId: number; + parentCommentId: number | null; + commentContent: string; + author?: CommentAuthor; + userId?: string; + nickname?: string; + profileUrl?: string | null; + createdAt?: string; + modifiedAt?: string; + deleted?: boolean; + isDeleted?: boolean; +} + +export interface CommentPageInfo { + page: number; + size: number; + totalElements: number; + totalPages: number; +} + +export interface CommentListResponse { + message: string; + pageInfo: CommentPageInfo; + data: BoardComment[]; +} + +export interface CreateCommentRequest { + boardId: number; + commentContent: string; + parentCommentId?: number | null; +} + +export interface CreateCommentResponse { + message: string; + commentId: number; + commentContent: string; + userId: string; + nickname: string; +} + +export interface UpdateCommentRequest { + commentContent: string; +} + +export interface UpdateCommentResponse { + message: string; +} + +export interface DeleteCommentResponse { + message: string; +} diff --git a/src/features/community/model/useCommentList.ts b/src/features/community/model/useCommentList.ts new file mode 100644 index 0000000..c03acc2 --- /dev/null +++ b/src/features/community/model/useCommentList.ts @@ -0,0 +1,14 @@ +import { useQuery } from '@tanstack/react-query'; + +import { getCommentList } from '@/features/community/api/comments'; +import { queryKeys } from '@/shared/lib/react-query/queryKey'; + +export function useCommentList(boardId: number | null, params: { page: number; size: number }) { + const isValidId = boardId !== null && !Number.isNaN(boardId); + + return useQuery({ + queryKey: isValidId ? queryKeys.boards.comments(boardId, params) : ['boards', 'comments', 'idle'], + queryFn: () => getCommentList(boardId as number, params), + enabled: isValidId, + }); +} diff --git a/src/features/community/model/useCreateComment.ts b/src/features/community/model/useCreateComment.ts new file mode 100644 index 0000000..aaf1950 --- /dev/null +++ b/src/features/community/model/useCreateComment.ts @@ -0,0 +1,20 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { createComment } from '@/features/community/api/comments'; +import type { CreateCommentRequest, CreateCommentResponse } from '@/features/community/model/types'; + +export function useCreateComment() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: createComment, + onSuccess: (_, variables) => { + void queryClient.invalidateQueries({ + queryKey: ['boards', variables.boardId, 'comments'], + }); + void queryClient.invalidateQueries({ + queryKey: ['boards', variables.boardId, 'detail'], + }); + }, + }); +} diff --git a/src/features/community/model/useDeleteComment.ts b/src/features/community/model/useDeleteComment.ts new file mode 100644 index 0000000..61c4985 --- /dev/null +++ b/src/features/community/model/useDeleteComment.ts @@ -0,0 +1,25 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { deleteComment } from '@/features/community/api/comments'; +import type { DeleteCommentResponse } from '@/features/community/model/types'; + +interface DeleteCommentVariables { + boardId: number; + commentId: number; +} + +export function useDeleteComment() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ commentId }) => deleteComment(commentId), + onSuccess: (_, variables) => { + void queryClient.invalidateQueries({ + queryKey: ['boards', variables.boardId, 'comments'], + }); + void queryClient.invalidateQueries({ + queryKey: ['boards', variables.boardId, 'detail'], + }); + }, + }); +} diff --git a/src/features/community/model/useUpdateComment.ts b/src/features/community/model/useUpdateComment.ts new file mode 100644 index 0000000..78af6da --- /dev/null +++ b/src/features/community/model/useUpdateComment.ts @@ -0,0 +1,23 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { updateComment } from '@/features/community/api/comments'; +import type { UpdateCommentRequest, UpdateCommentResponse } from '@/features/community/model/types'; + +interface UpdateCommentVariables { + boardId: number; + commentId: number; + payload: UpdateCommentRequest; +} + +export function useUpdateComment() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ commentId, payload }) => updateComment(commentId, payload), + onSuccess: (_, variables) => { + void queryClient.invalidateQueries({ + queryKey: ['boards', variables.boardId, 'comments'], + }); + }, + }); +} diff --git a/src/features/community/ui/BoardDetailContent.tsx b/src/features/community/ui/BoardDetailContent.tsx index 48a640d..7f2cdc9 100644 --- a/src/features/community/ui/BoardDetailContent.tsx +++ b/src/features/community/ui/BoardDetailContent.tsx @@ -1,42 +1,58 @@ +import { useState } from 'react'; import { Link } from 'react-router-dom'; -import type { BoardDetailResponse } from '../model/types'; +import type { BoardComment, BoardDetailResponse, CommentPageInfo } from '../model/types'; interface BoardDetailContentProps { board: BoardDetailResponse; + comments: BoardComment[]; + pageInfo?: CommentPageInfo; canManage: boolean; + currentUserNickname?: string | null; currentUserProfileUrl?: string | null; + isCommentsLoading: boolean; + commentsErrorMessage?: string; + isCreatingComment: boolean; + isUpdatingComment: boolean; + isDeletingComment: boolean; onDelete: () => void; + onRetryComments: () => void; + onCreateComment: (payload: { commentContent: string; parentCommentId?: number | null }) => Promise; + onUpdateComment: (payload: { commentId: number; commentContent: string }) => Promise; + onDeleteComment: (commentId: number) => Promise; + onChangeCommentPage: (page: number) => void; +} + +interface CommentThread extends BoardComment { + children: BoardComment[]; } const DETAIL_COPY = { authorFallback: '작성자', + anonymousAuthor: '익명', report: '신고', + reply: '답글', edit: '수정', delete: '삭제', + cancel: '취소', commentsTitle: '댓글', - placeholder: '댓글을 입력해주세요', + commentPlaceholder: '댓글을 입력해주세요.', + replyPlaceholder: '답글을 입력해주세요.', + emptyComments: '아직 댓글이 없어요. 첫 댓글을 남겨보세요.', submit: '등록', - submitAria: '댓글 등록 예정 버튼', + save: '저장', + submitAria: '댓글 등록 버튼', viewLabel: '조회', + deletedComment: '삭제된 댓글입니다.', + commentsFailedTitle: '댓글을 불러오지 못했어요.', + retry: '다시 시도', + previousPage: '이전', + nextPage: '다음', }; -const MOCK_COMMENTS = [ - { - id: 1, - nickname: '배웅배웅', - dateTime: '2025-10-14 11:25:00', - content: '어머~^^ 좋은 정보 감사드려요! 행복하세요 :D', - }, - { - id: 2, - nickname: '배웅배웅', - dateTime: '2025-10-14 11:25:00', - content: '어머~^^ 좋은 정보 감사드려요! 행복하세요 :D', - }, -]; - -function formatDateTime(value: string) { +function formatDateTime(value?: string) { + if (!value) return ''; + const date = new Date(value); if (Number.isNaN(date.getTime())) { @@ -52,11 +68,167 @@ function formatDateTime(value: string) { }).format(date); } -export function BoardDetailContent({ board, canManage, currentUserProfileUrl, onDelete }: BoardDetailContentProps) { +function getCommentAuthor(comment: BoardComment) { + return { + nickname: comment.author?.nickname?.trim() || comment.nickname?.trim() || DETAIL_COPY.anonymousAuthor, + profileUrl: comment.author?.profileUrl ?? comment.profileUrl ?? null, + }; +} + +function getCommentTimestamp(comment: BoardComment) { + return formatDateTime(comment.modifiedAt ?? comment.createdAt); +} + +function isDeletedComment(comment: BoardComment) { + return Boolean(comment.deleted || comment.isDeleted); +} + +function buildCommentThreads(comments: BoardComment[]) { + const topLevelComments: CommentThread[] = []; + const topLevelMap = new Map(); + + comments.forEach((comment) => { + if (comment.parentCommentId !== null) { + return; + } + + const thread: CommentThread = { + ...comment, + children: [], + }; + + topLevelComments.push(thread); + topLevelMap.set(comment.commentId, thread); + }); + + comments.forEach((comment) => { + if (comment.parentCommentId === null) { + return; + } + + const parent = topLevelMap.get(comment.parentCommentId); + + if (parent) { + parent.children.push(comment); + return; + } + + topLevelComments.push({ + ...comment, + children: [], + }); + }); + + return topLevelComments; +} + +export function BoardDetailContent({ + board, + comments, + pageInfo, + canManage, + currentUserNickname, + currentUserProfileUrl, + isCommentsLoading, + commentsErrorMessage, + isCreatingComment, + isUpdatingComment, + isDeletingComment, + onDelete, + onRetryComments, + onCreateComment, + onUpdateComment, + onDeleteComment, + onChangeCommentPage, +}: BoardDetailContentProps) { + const [draftComment, setDraftComment] = useState(''); + const [replyTargetId, setReplyTargetId] = useState(null); + const [replyDraft, setReplyDraft] = useState(''); + const [editingCommentId, setEditingCommentId] = useState(null); + const [editDraft, setEditDraft] = useState(''); + const [formError, setFormError] = useState(''); + const likeCount = board.likeCount ?? 0; - const commentCount = board.commentCount ?? MOCK_COMMENTS.length; + const commentCount = board.commentCount ?? pageInfo?.totalElements ?? comments.length; const authorName = board.nickname.trim() || DETAIL_COPY.authorFallback; const imageUrls = board.imageFileUrls.filter((imageUrl) => imageUrl.trim().length > 0); + const commentThreads = buildCommentThreads(comments); + + const submitComment = async () => { + const trimmed = draftComment.trim(); + + if (!trimmed) { + setFormError('댓글 내용을 입력해주세요.'); + return; + } + + setFormError(''); + + try { + await onCreateComment({ commentContent: trimmed }); + setDraftComment(''); + } catch (error) { + setFormError(error instanceof Error ? error.message : '댓글을 등록하지 못했어요.'); + } + }; + + const submitReply = async (parentCommentId: number) => { + const trimmed = replyDraft.trim(); + + if (!trimmed) { + setFormError('답글 내용을 입력해주세요.'); + return; + } + + setFormError(''); + + try { + await onCreateComment({ commentContent: trimmed, parentCommentId }); + setReplyDraft(''); + setReplyTargetId(null); + } catch (error) { + setFormError(error instanceof Error ? error.message : '답글을 등록하지 못했어요.'); + } + }; + + const submitEdit = async (commentId: number) => { + const trimmed = editDraft.trim(); + + if (!trimmed) { + setFormError('수정할 댓글 내용을 입력해주세요.'); + return; + } + + setFormError(''); + + try { + await onUpdateComment({ commentId, commentContent: trimmed }); + setEditingCommentId(null); + setEditDraft(''); + } catch (error) { + setFormError(error instanceof Error ? error.message : '댓글을 수정하지 못했어요.'); + } + }; + + const handleDeleteComment = async (commentId: number) => { + const confirmed = window.confirm('댓글을 삭제할까요?'); + + if (!confirmed) { + return; + } + + setFormError(''); + + try { + await onDeleteComment(commentId); + if (editingCommentId === commentId) { + setEditingCommentId(null); + setEditDraft(''); + } + } catch (error) { + setFormError(error instanceof Error ? error.message : '댓글을 삭제하지 못했어요.'); + } + }; return (
@@ -127,33 +299,159 @@ export function BoardDetailContent({ board, canManage, currentUserProfileUrl, on -
- {MOCK_COMMENTS.map((comment) => ( - - ))} -
- + {commentsErrorMessage ? ( +
+

{DETAIL_COPY.commentsFailedTitle}

+

{commentsErrorMessage}

+ +
+ ) : isCommentsLoading ? ( +
댓글을 불러오는 중이에요...
+ ) : commentThreads.length === 0 ? ( +
{DETAIL_COPY.emptyComments}
+ ) : ( +
+ {commentThreads.map((comment) => ( +
+ { + setFormError(''); + setReplyTargetId(null); + setReplyDraft(''); + setEditingCommentId(comment.commentId); + setEditDraft(comment.commentContent); + }} + onEditCancel={() => { + setEditingCommentId(null); + setEditDraft(''); + }} + onEditDraftChange={setEditDraft} + onEditSubmit={() => void submitEdit(comment.commentId)} + onDelete={() => void handleDeleteComment(comment.commentId)} + onReplyStart={() => { + setFormError(''); + setEditingCommentId(null); + setEditDraft(''); + setReplyTargetId((current) => (current === comment.commentId ? null : comment.commentId)); + setReplyDraft(''); + }} + /> -
-
-
- -
- {DETAIL_COPY.placeholder} -
+ {replyTargetId === comment.commentId ? ( + { + setReplyTargetId(null); + setReplyDraft(''); + }} + onSubmit={() => void submitReply(comment.commentId)} + /> + ) : null} + + {comment.children.map((reply) => ( + { + setFormError(''); + setReplyTargetId(null); + setReplyDraft(''); + setEditingCommentId(reply.commentId); + setEditDraft(reply.commentContent); + }} + onEditCancel={() => { + setEditingCommentId(null); + setEditDraft(''); + }} + onEditDraftChange={setEditDraft} + onEditSubmit={() => void submitEdit(reply.commentId)} + onDelete={() => void handleDeleteComment(reply.commentId)} + /> + ))} +
+ ))} +
+ )} + + {pageInfo && pageInfo.totalPages > 1 ? ( +
+ + + {pageInfo.page + 1} / {pageInfo.totalPages} +
+ ) : null} +
+ +
+
+
+
+ +
+