diff --git a/src/features/auth/model/types.ts b/src/features/auth/model/types.ts index 16b47a4..d6e58a6 100644 --- a/src/features/auth/model/types.ts +++ b/src/features/auth/model/types.ts @@ -410,6 +410,7 @@ export interface UpdateMyProfileResponse { export interface UserProfile { message: string; + userId?: string; email: string; name: string; nickname: string; diff --git a/src/features/community/api/boards.ts b/src/features/community/api/boards.ts index 2333f3b..1981c7f 100644 --- a/src/features/community/api/boards.ts +++ b/src/features/community/api/boards.ts @@ -6,6 +6,7 @@ import type { CreateBoardRequest, CreateBoardResponse, DeleteBoardResponse, + MyBoardListResponse, TempSavedBoardResponse, TempSaveBoardRequest, TempSaveBoardResponse, @@ -18,6 +19,11 @@ export async function getBoardList(params: { page: number; size: number }): Prom return response.data; } +export async function getMyBoardList(params: { page: number; size: number }): Promise { + const response = await apiClient.get('/boards/me', { params }); + return response.data; +} + export async function createBoard(payload: CreateBoardRequest): Promise { const response = await apiClient.post('/boards', payload); return response.data; diff --git a/src/features/community/api/comments.ts b/src/features/community/api/comments.ts new file mode 100644 index 0000000..d1c9141 --- /dev/null +++ b/src/features/community/api/comments.ts @@ -0,0 +1,39 @@ +import { apiClient } from '@/shared/api/axios'; + +import type { + CommentListResponse, + CreateCommentRequest, + CreateCommentResponse, + DeleteCommentResponse, + MyCommentListResponse, + 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 getMyCommentList(params: { page: number; size: number }): Promise { + const response = await apiClient.get('/comments/me', { 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..13d126a 100644 --- a/src/features/community/index.ts +++ b/src/features/community/index.ts @@ -3,16 +3,21 @@ export { deleteBoard, getBoardDetail, getBoardList, + getMyBoardList, getTempSavedBoard, tempSaveBoard, updateBoard, } from './api/boards'; +export { createComment, deleteComment, getCommentList, getMyCommentList, 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, + MY_ACTIVITY_STATUS_MESSAGES, } from './lib/constants'; export { clearStoredBoardDraftSessionKey, @@ -23,10 +28,16 @@ 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 { useMyBoardList } from './model/useMyBoardList'; +export { useMyCommentList } from './model/useMyCommentList'; 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 +47,27 @@ 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, + MyBoardListResponse, + MyCommentListItem, + MyCommentListResponse, 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..30a58ac 100644 --- a/src/features/community/lib/constants.ts +++ b/src/features/community/lib/constants.ts @@ -1,30 +1,51 @@ 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: '서버 오류가 발생했어요. 잠시 후 다시 시도해주세요.', +}; + +export const MY_ACTIVITY_STATUS_MESSAGES: Partial> = { + 400: '내 활동 조회 요청이 올바르지 않아요.', + 401: '로그인이 필요해요. 다시 로그인해주세요.', + 500: '서버 오류가 발생했어요. 잠시 후 다시 시도해주세요.', }; diff --git a/src/features/community/model/types.ts b/src/features/community/model/types.ts index 9eeb58b..8359481 100644 --- a/src/features/community/model/types.ts +++ b/src/features/community/model/types.ts @@ -75,3 +75,83 @@ export interface UpdateBoardResponse { export interface DeleteBoardResponse { message: string; } + +export interface MyBoardListResponse { + message: string; + boards: BoardListItem[]; +} + +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; +} + +export interface MyCommentListItem { + commentId: number; + boardId: number; + boardTitle: string; + parentCommentId: number | null; + commentContent: string; + createdAt?: string; + modifiedAt?: string; +} + +export interface MyCommentListResponse { + message: string; + pageInfo: CommentPageInfo; + data: MyCommentListItem[]; +} 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/useMyBoardList.ts b/src/features/community/model/useMyBoardList.ts new file mode 100644 index 0000000..acc6ff7 --- /dev/null +++ b/src/features/community/model/useMyBoardList.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query'; + +import { getMyBoardList } from '../api/boards'; +import { queryKeys } from '@/shared/lib/react-query/queryKey'; + +export function useMyBoardList(params: { page: number; size: number }, options?: { enabled?: boolean }) { + return useQuery({ + queryKey: queryKeys.boards.mine(params), + queryFn: () => getMyBoardList(params), + enabled: options?.enabled, + }); +} diff --git a/src/features/community/model/useMyCommentList.ts b/src/features/community/model/useMyCommentList.ts new file mode 100644 index 0000000..0e70012 --- /dev/null +++ b/src/features/community/model/useMyCommentList.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query'; + +import { getMyCommentList } from '../api/comments'; +import { queryKeys } from '@/shared/lib/react-query/queryKey'; + +export function useMyCommentList(params: { page: number; size: number }, options?: { enabled?: boolean }) { + return useQuery({ + queryKey: queryKeys.comments.mine(params), + queryFn: () => getMyCommentList(params), + enabled: options?.enabled, + }); +} 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..3d7ff6c 100644 --- a/src/features/community/ui/BoardDetailContent.tsx +++ b/src/features/community/ui/BoardDetailContent.tsx @@ -1,42 +1,66 @@ +import { useLayoutEffect, useRef, 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; - currentUserProfileUrl?: string | null; + currentUserId?: 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: '댓글을 불러오지 못했어요.', + loadingComments: '댓글을 불러오는 중이에요...', + retry: '다시 시도', + previousPage: '이전', + nextPage: '다음', + commentRequired: '댓글 내용을 입력해주세요.', + replyRequired: '답글 내용을 입력해주세요.', + editRequired: '수정할 댓글 내용을 입력해주세요.', + commentCreateFailed: '댓글을 등록하지 못했어요.', + replyCreateFailed: '답글을 등록하지 못했어요.', + commentUpdateFailed: '댓글을 수정하지 못했어요.', + commentDeleteConfirm: '댓글을 삭제할까요?', + commentDeleteFailed: '댓글을 삭제하지 못했어요.', }; -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 +76,194 @@ 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, + }; +} + +function getCommentUserId(comment: BoardComment) { + return comment.author?.userId ?? comment.userId ?? 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, + currentUserId, + 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 [commentError, setCommentError] = useState(''); + const [replyError, setReplyError] = useState<{ commentId: number; message: string } | null>(null); + const [editError, setEditError] = useState<{ commentId: number; message: string } | null>(null); + const [deleteError, setDeleteError] = useState<{ commentId: number; message: string } | null>(null); + 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 clearCommentScopedErrors = (commentId?: number) => { + if (commentId === undefined) { + setReplyError(null); + setEditError(null); + setDeleteError(null); + return; + } + + setReplyError((current) => (current?.commentId === commentId ? null : current)); + setEditError((current) => (current?.commentId === commentId ? null : current)); + setDeleteError((current) => (current?.commentId === commentId ? null : current)); + }; + + const submitComment = async () => { + const trimmed = draftComment.trim(); + + if (!trimmed) { + setCommentError(DETAIL_COPY.commentRequired); + return; + } + + setCommentError(''); + + try { + await onCreateComment({ commentContent: trimmed }); + setDraftComment(''); + } catch (error) { + setCommentError(error instanceof Error ? error.message : DETAIL_COPY.commentCreateFailed); + } + }; + + const submitReply = async (parentCommentId: number) => { + const trimmed = replyDraft.trim(); + + if (!trimmed) { + setReplyError({ commentId: parentCommentId, message: DETAIL_COPY.replyRequired }); + return; + } + + setReplyError(null); + + try { + await onCreateComment({ commentContent: trimmed, parentCommentId }); + setReplyDraft(''); + setReplyTargetId(null); + } catch (error) { + setReplyError({ + commentId: parentCommentId, + message: error instanceof Error ? error.message : DETAIL_COPY.replyCreateFailed, + }); + } + }; + + const submitEdit = async (commentId: number) => { + const trimmed = editDraft.trim(); + + if (!trimmed) { + setEditError({ commentId, message: DETAIL_COPY.editRequired }); + return; + } + + setEditError(null); + + try { + await onUpdateComment({ commentId, commentContent: trimmed }); + setEditingCommentId(null); + setEditDraft(''); + } catch (error) { + setEditError({ + commentId, + message: error instanceof Error ? error.message : DETAIL_COPY.commentUpdateFailed, + }); + } + }; + + const handleDeleteComment = async (commentId: number) => { + const confirmed = window.confirm(DETAIL_COPY.commentDeleteConfirm); + + if (!confirmed) { + return; + } + + setDeleteError(null); + + try { + await onDeleteComment(commentId); + if (editingCommentId === commentId) { + setEditingCommentId(null); + setEditDraft(''); + } + } catch (error) { + setDeleteError({ + commentId, + message: error instanceof Error ? error.message : DETAIL_COPY.commentDeleteFailed, + }); + } + }; return (
@@ -127,32 +334,179 @@ export function BoardDetailContent({ board, canManage, currentUserProfileUrl, on -
- {MOCK_COMMENTS.map((comment) => ( - - ))} -
- + {commentsErrorMessage ? ( +
+

{DETAIL_COPY.commentsFailedTitle}

+

{commentsErrorMessage}

+ +
+ ) : isCommentsLoading ? ( +
{DETAIL_COPY.loadingComments}
+ ) : commentThreads.length === 0 ? ( +
{DETAIL_COPY.emptyComments}
+ ) : ( +
+ {commentThreads.map((comment) => ( +
+ { + clearCommentScopedErrors(comment.commentId); + setReplyTargetId(null); + setReplyDraft(''); + setEditingCommentId(comment.commentId); + setEditDraft(comment.commentContent); + }} + onEditCancel={() => { + setEditingCommentId(null); + setEditDraft(''); + clearCommentScopedErrors(comment.commentId); + }} + onEditDraftChange={(value) => { + setEditDraft(value); + setEditError((current) => (current?.commentId === comment.commentId ? null : current)); + }} + onEditSubmit={() => void submitEdit(comment.commentId)} + onDelete={() => void handleDeleteComment(comment.commentId)} + onReplyStart={() => { + clearCommentScopedErrors(comment.commentId); + setEditingCommentId(null); + setEditDraft(''); + setReplyTargetId((current) => (current === comment.commentId ? null : comment.commentId)); + setReplyDraft(''); + }} + /> -
-
-
- -
- {DETAIL_COPY.placeholder} -
+ {replyTargetId === comment.commentId ? ( + { + setReplyDraft(value); + setReplyError((current) => (current?.commentId === comment.commentId ? null : current)); + }} + onCancel={() => { + setReplyTargetId(null); + setReplyDraft(''); + clearCommentScopedErrors(comment.commentId); + }} + onSubmit={() => void submitReply(comment.commentId)} + /> + ) : null} + + {comment.children.map((reply) => ( + { + clearCommentScopedErrors(reply.commentId); + setReplyTargetId(null); + setReplyDraft(''); + setEditingCommentId(reply.commentId); + setEditDraft(reply.commentContent); + }} + onEditCancel={() => { + setEditingCommentId(null); + setEditDraft(''); + clearCommentScopedErrors(reply.commentId); + }} + onEditDraftChange={(value) => { + setEditDraft(value); + setEditError((current) => (current?.commentId === reply.commentId ? null : current)); + }} + onEditSubmit={() => void submitEdit(reply.commentId)} + onDelete={() => void handleDeleteComment(reply.commentId)} + /> + ))} +
+ ))} +
+ )} + + {pageInfo && pageInfo.totalPages > 1 ? ( +
+ + {pageInfo.page + 1} / {pageInfo.totalPages} + + +
+ ) : null} +
+ +
+
+
+
+
+ { + setDraftComment(value); + if (commentError) { + setCommentError(''); + } + }} + placeholder={DETAIL_COPY.commentPlaceholder} + className="w-full rounded-[20px] border border-neutral-200 bg-neutral-50 px-4 py-3 text-sm leading-6 text-neutral-800 outline-none transition focus:border-neutral-300" + /> + {commentError ?

{commentError}

: null} +
+ +
@@ -219,34 +573,209 @@ function SocialStat({ kind, value }: { kind: 'like' | 'comment'; value: number } ); } -function CommentRow({ nickname, dateTime, content }: { nickname: string; dateTime: string; content: string }) { +interface CommentRowProps { + comment: BoardComment; + currentUserId?: string | null; + indent?: boolean; + isEditing: boolean; + editDraft: string; + isMutating: boolean; + errorMessage?: string; + onEditStart?: () => void; + onEditCancel: () => void; + onEditDraftChange: (value: string) => void; + onEditSubmit: () => void; + onDelete: () => void; + onReplyStart?: () => void; +} + +function CommentRow({ + comment, + currentUserId, + indent = false, + isEditing, + editDraft, + isMutating, + errorMessage, + onEditStart, + onEditCancel, + onEditDraftChange, + onEditSubmit, + onDelete, + onReplyStart, +}: CommentRowProps) { + const { nickname } = getCommentAuthor(comment); + const commentUserId = getCommentUserId(comment); + const canManage = Boolean( + currentUserId && commentUserId && currentUserId === commentUserId && !isDeletedComment(comment), + ); + const content = isDeletedComment(comment) ? DETAIL_COPY.deletedComment : comment.commentContent; + const dateTime = getCommentTimestamp(comment); + return ( -
+
-
- -
-

{nickname}

-

{dateTime}

-
+
+

{nickname}

+ {dateTime ?

{dateTime}

: null}
-
- - - + + {!isDeletedComment(comment) ? ( +
+ {!canManage ? ( + + ) : null} + {!indent && onReplyStart ? ( + + ) : null} + {canManage ? ( + <> + + + + ) : null} +
+ ) : null} +
+ + {isEditing ? ( +
+ + {errorMessage ?

{errorMessage}

: null} +
+ + +
+ ) : ( + <> +

+ {content} +

+ {errorMessage ?

{errorMessage}

: null} + + )} +
+ ); +} + +interface ReplyComposerProps { + value: string; + placeholder: string; + isPending: boolean; + errorMessage?: string; + onChange: (value: string) => void; + onCancel: () => void; + onSubmit: () => void; +} + +function ReplyComposer({ + value, + placeholder, + isPending, + errorMessage, + onChange, + onCancel, + onSubmit, +}: ReplyComposerProps) { + return ( +
+ + {errorMessage ?

{errorMessage}

: null} +
+ +
-

{content}

); } +interface AutoSizeTextareaProps { + value: string; + placeholder?: string; + className?: string; + onChange: (value: string) => void; +} + +function AutoSizeTextarea({ value, placeholder, className, onChange }: AutoSizeTextareaProps) { + const textareaRef = useRef(null); + + useLayoutEffect(() => { + const textarea = textareaRef.current; + + if (!textarea) { + return; + } + + textarea.style.height = '0px'; + textarea.style.height = `${textarea.scrollHeight}px`; + }, [value]); + + return ( +