From 65fc22a3dce073bab2676dc09337ba1be3940a4c Mon Sep 17 00:00:00 2001 From: sooloin Date: Sun, 14 Jun 2026 01:14:39 +0900 Subject: [PATCH 1/3] =?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=EB=B0=98?= =?UTF-8?q?=EC=9D=91=20=EA=B8=B0=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 - 게시글 좋아요/싫어요 반응 API 연동 - 게시글 상세에서 반응 추가, 변경, 취소 기능 구현 - 반응 후 상세/목록 UI가 즉시 반영되도록 낙관적 업데이트 적용 - 커뮤니티 목록에서는 좋아요 수만 노출되도록 유지 - 404, 409 등 반응 관련 예외 상황 메시지 처리 `#56` --- src/features/community/api/reactions.ts | 21 ++++ src/features/community/index.ts | 8 ++ src/features/community/lib/constants.ts | 13 +- src/features/community/lib/reactions.ts | 68 +++++++++++ src/features/community/model/types.ts | 20 +++ .../community/model/useBoardReaction.ts | 114 ++++++++++++++++++ .../community/ui/BoardDetailContent.tsx | 85 ++++++++++++- src/pages/community/BoardDetailPage.tsx | 28 +++++ 8 files changed, 351 insertions(+), 6 deletions(-) create mode 100644 src/features/community/api/reactions.ts create mode 100644 src/features/community/lib/reactions.ts create mode 100644 src/features/community/model/useBoardReaction.ts diff --git a/src/features/community/api/reactions.ts b/src/features/community/api/reactions.ts new file mode 100644 index 0000000..2a436b4 --- /dev/null +++ b/src/features/community/api/reactions.ts @@ -0,0 +1,21 @@ +import { apiClient } from '@/shared/api/axios'; + +import type { BoardReactionResponse, CreateBoardReactionRequest, UpdateBoardReactionRequest } from '../model/types'; + +export async function createBoardReaction(payload: CreateBoardReactionRequest): Promise { + const response = await apiClient.post('/reactions/board', payload); + return response.data; +} + +export async function updateBoardReaction( + boardId: number, + payload: UpdateBoardReactionRequest, +): Promise { + const response = await apiClient.patch(`/reactions/board/${boardId}`, payload); + return response.data; +} + +export async function deleteBoardReaction(boardId: number): Promise { + const response = await apiClient.delete(`/reactions/board/${boardId}`); + return response.data; +} diff --git a/src/features/community/index.ts b/src/features/community/index.ts index 13d126a..4f4916a 100644 --- a/src/features/community/index.ts +++ b/src/features/community/index.ts @@ -9,16 +9,19 @@ export { updateBoard, } from './api/boards'; export { createComment, deleteComment, getCommentList, getMyCommentList, updateComment } from './api/comments'; +export { deleteBoardReaction, createBoardReaction, updateBoardReaction } from './api/reactions'; export { BOARD_DETAIL_STATUS_MESSAGES, BOARD_DRAFT_STATUS_MESSAGES, BOARD_LIST_STATUS_MESSAGES, BOARD_MUTATION_STATUS_MESSAGES, + BOARD_REACTION_STATUS_MESSAGES, COMMENT_LIST_STATUS_MESSAGES, COMMENT_MUTATION_STATUS_MESSAGES, COMMUNITY_DRAFT_SESSION_KEY, MY_ACTIVITY_STATUS_MESSAGES, } from './lib/constants'; +export { getBoardReactionType, normalizeReactionType } from './lib/reactions'; export { clearStoredBoardDraftSessionKey, getStoredBoardDraftSessionKey, @@ -28,6 +31,7 @@ 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 { useBoardReaction } from './model/useBoardReaction'; export { useCommentList } from './model/useCommentList'; export { useCreateBoard } from './model/useCreateBoard'; export { useCreateComment } from './model/useCreateComment'; @@ -52,22 +56,26 @@ export type { BoardListItem, BoardListResponse, BoardPayload, + BoardReactionResponse, CommentListResponse, CommentPageInfo, CreateCommentRequest, CreateCommentResponse, CreateBoardRequest, CreateBoardResponse, + CreateBoardReactionRequest, DeleteCommentResponse, DeleteBoardResponse, MyBoardListResponse, MyCommentListItem, MyCommentListResponse, + ReactionType, TempSavedBoardResponse, TempSaveBoardRequest, TempSaveBoardResponse, UpdateCommentRequest, UpdateCommentResponse, + UpdateBoardReactionRequest, UpdateBoardRequest, UpdateBoardResponse, } from './model/types'; diff --git a/src/features/community/lib/constants.ts b/src/features/community/lib/constants.ts index 30a58ac..c3c6360 100644 --- a/src/features/community/lib/constants.ts +++ b/src/features/community/lib/constants.ts @@ -1,7 +1,7 @@ export const COMMUNITY_DRAFT_SESSION_KEY = 'community-board-draft-session-key'; export const BOARD_LIST_STATUS_MESSAGES: Partial> = { - 400: '잘못된 요청이에요.', + 400: '게시글 목록 요청이 올바르지 않아요.', 401: '로그인이 필요해요. 다시 로그인해주세요.', 500: '서버 오류가 발생했어요. 잠시 후 다시 시도해주세요.', }; @@ -13,8 +13,17 @@ export const BOARD_MUTATION_STATUS_MESSAGES: Partial> = { 500: '서버 오류가 발생했어요. 잠시 후 다시 시도해주세요.', }; +export const BOARD_REACTION_STATUS_MESSAGES: Partial> = { + 400: '반응 요청이 올바르지 않아요. 다시 시도해주세요.', + 401: '로그인이 필요한 기능이에요. 다시 로그인해주세요.', + 403: '이 게시물에 반응할 권한이 없어요.', + 404: '게시물을 찾을 수 없거나 남긴 반응이 없어요.', + 409: '이미 같은 반응을 남긴 게시물이에요.', + 500: '서버 오류가 발생했어요. 잠시 후 다시 시도해주세요.', +}; + export const BOARD_DETAIL_STATUS_MESSAGES: Partial> = { - 400: '잘못된 게시글 요청이에요.', + 400: '게시글 요청이 올바르지 않아요.', 401: '로그인이 필요해요. 다시 로그인해주세요.', 403: '게시글을 조회할 권한이 없어요.', 404: '게시글을 찾을 수 없어요.', diff --git a/src/features/community/lib/reactions.ts b/src/features/community/lib/reactions.ts new file mode 100644 index 0000000..cd3cfca --- /dev/null +++ b/src/features/community/lib/reactions.ts @@ -0,0 +1,68 @@ +import type { BoardDetailResponse, BoardListItem, ReactionType } from '../model/types'; + +const REACTION_TYPES: ReactionType[] = ['LIKE', 'DISLIKE']; + +function clampCount(value: number) { + return Math.max(0, value); +} + +export function normalizeReactionType(value: unknown): ReactionType | null { + if (typeof value !== 'string') { + return null; + } + + const normalized = value.toUpperCase(); + return REACTION_TYPES.includes(normalized as ReactionType) ? (normalized as ReactionType) : null; +} + +export function getBoardReactionType(board: Partial | null | undefined): ReactionType | null { + if (!board) { + return null; + } + + return ( + normalizeReactionType(board.reactionType) ?? + normalizeReactionType(board.myReactionType) ?? + normalizeReactionType(board.currentUserReactionType) ?? + normalizeReactionType(board.userReactionType) + ); +} + +export function getReactionCountDelta(previousReaction: ReactionType | null, nextReaction: ReactionType | null) { + const likeDelta = (nextReaction === 'LIKE' ? 1 : 0) - (previousReaction === 'LIKE' ? 1 : 0); + const dislikeDelta = (nextReaction === 'DISLIKE' ? 1 : 0) - (previousReaction === 'DISLIKE' ? 1 : 0); + + return { likeDelta, dislikeDelta }; +} + +export function applyReactionToBoardDetail( + board: BoardDetailResponse, + previousReaction: ReactionType | null, + nextReaction: ReactionType | null, +): BoardDetailResponse { + const { likeDelta, dislikeDelta } = getReactionCountDelta(previousReaction, nextReaction); + + return { + ...board, + likeCount: clampCount((board.likeCount ?? 0) + likeDelta), + dislikeCount: clampCount((board.dislikeCount ?? 0) + dislikeDelta), + reactionType: nextReaction, + myReactionType: nextReaction, + currentUserReactionType: nextReaction, + userReactionType: nextReaction, + }; +} + +export function applyReactionToBoardListItem( + board: BoardListItem, + previousReaction: ReactionType | null, + nextReaction: ReactionType | null, +): BoardListItem { + const { likeDelta, dislikeDelta } = getReactionCountDelta(previousReaction, nextReaction); + + return { + ...board, + likeCount: clampCount(board.likeCount + likeDelta), + dislikeCount: clampCount(board.dislikeCount + dislikeDelta), + }; +} diff --git a/src/features/community/model/types.ts b/src/features/community/model/types.ts index 8359481..de605b0 100644 --- a/src/features/community/model/types.ts +++ b/src/features/community/model/types.ts @@ -12,6 +12,8 @@ export interface BoardListItem { modifiedAt: string; } +export type ReactionType = 'LIKE' | 'DISLIKE'; + export interface BoardListResponse { message: string; boards: BoardListItem[]; @@ -60,10 +62,28 @@ export interface BoardDetailResponse { profileUrl: string | null; nickname: string; likeCount?: number; + dislikeCount?: number; commentCount?: number; viewCount: number; boardCreatedAt: string; modifiedAt: string; + reactionType?: ReactionType | null; + myReactionType?: ReactionType | null; + currentUserReactionType?: ReactionType | null; + userReactionType?: ReactionType | null; +} + +export interface CreateBoardReactionRequest { + boardId: number; + reactionType: ReactionType; +} + +export interface UpdateBoardReactionRequest { + reactionType: ReactionType; +} + +export interface BoardReactionResponse { + message: string; } export type UpdateBoardRequest = BoardPayload; diff --git a/src/features/community/model/useBoardReaction.ts b/src/features/community/model/useBoardReaction.ts new file mode 100644 index 0000000..8f0da64 --- /dev/null +++ b/src/features/community/model/useBoardReaction.ts @@ -0,0 +1,114 @@ +import { useMutation, useQueryClient, type InfiniteData } from '@tanstack/react-query'; + +import { createBoardReaction, deleteBoardReaction, updateBoardReaction } from '../api/reactions'; +import { applyReactionToBoardDetail, applyReactionToBoardListItem } from '../lib/reactions'; +import type { BoardDetailResponse, BoardListResponse, MyBoardListResponse, ReactionType } from './types'; +import { queryKeys } from '@/shared/lib/react-query/queryKey'; + +interface BoardReactionVariables { + boardId: number; + nextReactionType: ReactionType; + currentReactionType: ReactionType | null; +} + +interface BoardReactionContext { + previousDetail?: BoardDetailResponse; + previousListInfinite?: InfiniteData; + previousMineQueries: Array; +} + +function updateBoardListResponse( + data: BoardListResponse, + boardId: number, + previousReaction: ReactionType | null, + nextReaction: ReactionType | null, +) { + return { + ...data, + boards: data.boards.map((board) => + board.boardId === boardId ? applyReactionToBoardListItem(board, previousReaction, nextReaction) : board, + ), + }; +} + +export function useBoardReaction() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ boardId, nextReactionType, currentReactionType }: BoardReactionVariables) => { + if (currentReactionType === null) { + return createBoardReaction({ boardId, reactionType: nextReactionType }); + } + + if (currentReactionType === nextReactionType) { + return deleteBoardReaction(boardId); + } + + return updateBoardReaction(boardId, { reactionType: nextReactionType }); + }, + onMutate: async ({ boardId, nextReactionType, currentReactionType }): Promise => { + const optimisticReactionType = currentReactionType === nextReactionType ? null : nextReactionType; + + await Promise.all([ + queryClient.cancelQueries({ queryKey: queryKeys.boards.detail(boardId) }), + queryClient.cancelQueries({ queryKey: queryKeys.boards.listInfinite() }), + queryClient.cancelQueries({ queryKey: ['boards', 'mine'] }), + ]); + + const previousDetail = queryClient.getQueryData(queryKeys.boards.detail(boardId)); + const previousListInfinite = queryClient.getQueryData>( + queryKeys.boards.listInfinite(), + ); + const previousMineQueries = queryClient.getQueriesData({ queryKey: ['boards', 'mine'] }); + + queryClient.setQueryData(queryKeys.boards.detail(boardId), (current) => + current ? applyReactionToBoardDetail(current, currentReactionType, optimisticReactionType) : current, + ); + + queryClient.setQueryData>(queryKeys.boards.listInfinite(), (current) => + current + ? { + ...current, + pages: current.pages.map((page) => + updateBoardListResponse(page, boardId, currentReactionType, optimisticReactionType), + ), + } + : current, + ); + + queryClient.setQueriesData({ queryKey: ['boards', 'mine'] }, (current) => + current ? updateBoardListResponse(current, boardId, currentReactionType, optimisticReactionType) : current, + ); + + return { + previousDetail, + previousListInfinite, + previousMineQueries, + }; + }, + onError: (_error, variables, context) => { + if (!context) { + return; + } + + if (context.previousDetail) { + queryClient.setQueryData(queryKeys.boards.detail(variables.boardId), context.previousDetail); + } + + if (context.previousListInfinite) { + queryClient.setQueryData(queryKeys.boards.listInfinite(), context.previousListInfinite); + } + + context.previousMineQueries.forEach(([queryKey, data]) => { + queryClient.setQueryData(queryKey, data); + }); + }, + onSettled: async (_data, _error, variables) => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.boards.detail(variables.boardId) }), + queryClient.invalidateQueries({ queryKey: queryKeys.boards.listInfinite() }), + queryClient.invalidateQueries({ queryKey: ['boards', 'mine'] }), + ]); + }, + }); +} diff --git a/src/features/community/ui/BoardDetailContent.tsx b/src/features/community/ui/BoardDetailContent.tsx index 3d7ff6c..52f2184 100644 --- a/src/features/community/ui/BoardDetailContent.tsx +++ b/src/features/community/ui/BoardDetailContent.tsx @@ -1,7 +1,7 @@ import { useLayoutEffect, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; -import type { BoardComment, BoardDetailResponse, CommentPageInfo } from '../model/types'; +import type { BoardComment, BoardDetailResponse, CommentPageInfo, ReactionType } from '../model/types'; interface BoardDetailContentProps { board: BoardDetailResponse; @@ -14,7 +14,11 @@ interface BoardDetailContentProps { isCreatingComment: boolean; isUpdatingComment: boolean; isDeletingComment: boolean; + currentReactionType: ReactionType | null; + isReacting: boolean; + reactionErrorMessage?: string; onDelete: () => void; + onReact: (reactionType: ReactionType) => Promise; onRetryComments: () => void; onCreateComment: (payload: { commentContent: string; parentCommentId?: number | null }) => Promise; onUpdateComment: (payload: { commentId: number; commentContent: string }) => Promise; @@ -145,11 +149,15 @@ export function BoardDetailContent({ isUpdatingComment, isDeletingComment, onDelete, + onReact, onRetryComments, onCreateComment, onUpdateComment, onDeleteComment, onChangeCommentPage, + currentReactionType, + isReacting, + reactionErrorMessage, }: BoardDetailContentProps) { const [draftComment, setDraftComment] = useState(''); const [replyTargetId, setReplyTargetId] = useState(null); @@ -162,6 +170,7 @@ export function BoardDetailContent({ const [deleteError, setDeleteError] = useState<{ commentId: number; message: string } | null>(null); const likeCount = board.likeCount ?? 0; + const dislikeCount = board.dislikeCount ?? 0; 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); @@ -318,10 +327,32 @@ export function BoardDetailContent({ {board.boardContent}

-
- - +
+
+ void onReact('LIKE')} + /> + void onReact('DISLIKE')} + /> +
+ +
+ +
+ + {reactionErrorMessage ?

{reactionErrorMessage}

: null}
@@ -573,6 +604,40 @@ function SocialStat({ kind, value }: { kind: 'like' | 'comment'; value: number } ); } +interface ReactionButtonProps { + reactionType: ReactionType; + label: string; + count: number; + active: boolean; + disabled: boolean; + onClick: () => void; +} + +function ReactionButton({ reactionType, label, count, active, disabled, onClick }: ReactionButtonProps) { + const activeClass = + reactionType === 'LIKE' + ? 'border-[#ef3c32]/30 bg-[#ef3c32]/8 text-[#d93025]' + : 'border-[#4b5563]/30 bg-neutral-100 text-neutral-700'; + const idleClass = 'border-neutral-200 bg-white text-neutral-500 hover:border-neutral-300 hover:text-neutral-700'; + + return ( + + ); +} + interface CommentRowProps { comment: BoardComment; currentUserId?: string | null; @@ -792,6 +857,18 @@ function ThumbsUpIcon({ className }: { className?: string }) { ); } +function ThumbsDownIcon({ className }: { className?: string }) { + return ( + + + + ); +} + function CommentIcon({ className }: { className?: string }) { return ( diff --git a/src/pages/community/BoardDetailPage.tsx b/src/pages/community/BoardDetailPage.tsx index 68233bb..d5695ef 100644 --- a/src/pages/community/BoardDetailPage.tsx +++ b/src/pages/community/BoardDetailPage.tsx @@ -6,12 +6,15 @@ import { useCurrentUser } from '@/features/auth'; import { BOARD_DETAIL_STATUS_MESSAGES, BOARD_MUTATION_STATUS_MESSAGES, + BOARD_REACTION_STATUS_MESSAGES, BoardDetailContent, COMMENT_LIST_STATUS_MESSAGES, COMMENT_MUTATION_STATUS_MESSAGES, CommunityLayout, DeleteBoardDialog, + getBoardReactionType, useBoardDetail, + useBoardReaction, useCommentList, useCreateComment, useDeleteBoard, @@ -56,6 +59,8 @@ export function BoardDetailPage() { const { mutateAsync: createComment, isPending: isCreatingComment } = useCreateComment(); const { mutateAsync: updateComment, isPending: isUpdatingComment } = useUpdateComment(); const { mutateAsync: deleteComment, isPending: isDeletingComment } = useDeleteComment(); + const { mutateAsync: reactToBoard, isPending: isReacting } = useBoardReaction(); + const [reactionError, setReactionError] = useState(''); const canManage = Boolean(board && nickname && board.nickname.trim() === nickname.trim()); @@ -160,6 +165,25 @@ export function BoardDetailPage() { } }; + const handleBoardReaction = async (reactionType: 'LIKE' | 'DISLIKE') => { + try { + setReactionError(''); + await reactToBoard({ + boardId, + nextReactionType: reactionType, + currentReactionType: getBoardReactionType(board), + }); + } catch (reactionActionError) { + setReactionError( + getApiErrorMessage( + reactionActionError, + '반응을 처리하지 못했어요. 잠시 후 다시 시도해주세요.', + BOARD_REACTION_STATUS_MESSAGES, + ), + ); + } + }; + return ( setDeleteDialogOpen(true)} + onReact={handleBoardReaction} onRetryComments={() => void commentsQuery.refetch()} onCreateComment={handleCreateComment} onUpdateComment={handleUpdateComment} From 869439036bc4d2b3ccbbeb4a41881d272423fb6e Mon Sep 17 00:00:00 2001 From: sooloin Date: Sun, 14 Jun 2026 22:49:38 +0900 Subject: [PATCH 2/3] =?UTF-8?q?:bug:=20Fix:=20=EC=BB=A4=EB=AE=A4=EB=8B=88?= =?UTF-8?q?=ED=8B=B0=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EB=B0=98=EC=9D=91=20?= =?UTF-8?q?UX=20=EB=B0=8F=20=EC=83=81=EC=84=B8=20=EB=B0=98=EC=98=81=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 게시글 상세 조회 응답의 좋아요/싫어요 수를 상세 화면에 반영 - 게시글 반응 API 생성/변경/취소 로직 정리 - 내가 누른 반응이 상세 화면에서 active 상태로 유지되도록 개선 - 반대 반응 선택 시 기존 반응 변경 흐름이 정상 동작하도록 수정 - 내 게시물 반응 시 권한 에러 대신 안내 토스트가 노출되도록 변경 - 중복 반응(409) 상황을 에러 문구 대신 토스트로 안내하도록 개선 - 커뮤니티 목록은 기존처럼 좋아요 수만 노출되도록 유지 `#56` --- src/features/community/index.ts | 2 +- src/features/community/model/types.ts | 4 +- .../community/ui/BoardDetailContent.tsx | 16 ++-- src/pages/community/BoardDetailPage.tsx | 76 ++++++++++++++++++- 4 files changed, 84 insertions(+), 14 deletions(-) diff --git a/src/features/community/index.ts b/src/features/community/index.ts index 4f4916a..c092978 100644 --- a/src/features/community/index.ts +++ b/src/features/community/index.ts @@ -21,7 +21,7 @@ export { COMMUNITY_DRAFT_SESSION_KEY, MY_ACTIVITY_STATUS_MESSAGES, } from './lib/constants'; -export { getBoardReactionType, normalizeReactionType } from './lib/reactions'; +export { getBoardReactionType, getReactionCountDelta, normalizeReactionType } from './lib/reactions'; export { clearStoredBoardDraftSessionKey, getStoredBoardDraftSessionKey, diff --git a/src/features/community/model/types.ts b/src/features/community/model/types.ts index de605b0..c0cbf3f 100644 --- a/src/features/community/model/types.ts +++ b/src/features/community/model/types.ts @@ -61,8 +61,8 @@ export interface BoardDetailResponse { imageFileUrls: string[]; profileUrl: string | null; nickname: string; - likeCount?: number; - dislikeCount?: number; + likeCount: number; + dislikeCount: number; commentCount?: number; viewCount: number; boardCreatedAt: string; diff --git a/src/features/community/ui/BoardDetailContent.tsx b/src/features/community/ui/BoardDetailContent.tsx index 52f2184..b9a82b1 100644 --- a/src/features/community/ui/BoardDetailContent.tsx +++ b/src/features/community/ui/BoardDetailContent.tsx @@ -14,6 +14,8 @@ interface BoardDetailContentProps { isCreatingComment: boolean; isUpdatingComment: boolean; isDeletingComment: boolean; + displayedLikeCount: number; + displayedDislikeCount: number; currentReactionType: ReactionType | null; isReacting: boolean; reactionErrorMessage?: string; @@ -148,6 +150,11 @@ export function BoardDetailContent({ isCreatingComment, isUpdatingComment, isDeletingComment, + displayedLikeCount, + displayedDislikeCount, + currentReactionType, + isReacting, + reactionErrorMessage, onDelete, onReact, onRetryComments, @@ -155,9 +162,6 @@ export function BoardDetailContent({ onUpdateComment, onDeleteComment, onChangeCommentPage, - currentReactionType, - isReacting, - reactionErrorMessage, }: BoardDetailContentProps) { const [draftComment, setDraftComment] = useState(''); const [replyTargetId, setReplyTargetId] = useState(null); @@ -169,8 +173,6 @@ export function BoardDetailContent({ 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 dislikeCount = board.dislikeCount ?? 0; 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); @@ -332,7 +334,7 @@ export function BoardDetailContent({ void onReact('LIKE')} @@ -340,7 +342,7 @@ export function BoardDetailContent({ void onReact('DISLIKE')} diff --git a/src/pages/community/BoardDetailPage.tsx b/src/pages/community/BoardDetailPage.tsx index d5695ef..271f727 100644 --- a/src/pages/community/BoardDetailPage.tsx +++ b/src/pages/community/BoardDetailPage.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useCurrentUser } from '@/features/auth'; @@ -21,7 +21,7 @@ import { useDeleteComment, useUpdateComment, } from '@/features/community'; -import { getApiErrorMessage } from '@/shared/lib/api/errorMessage'; +import { getApiErrorMessage, getApiErrorStatus } from '@/shared/lib/api/errorMessage'; import { LoadingSpinner } from '@/shared/ui'; const DETAIL_PAGE_COPY = { @@ -32,8 +32,20 @@ const DETAIL_PAGE_COPY = { loadFailedFallback: '게시글 조회에 실패했어요. 잠시 후 다시 시도해주세요.', retry: '다시 시도', deleteFailedFallback: '게시글을 삭제하지 못했어요. 잠시 후 다시 시도해주세요.', + ownReactionNotice: '내가 작성한 게시물에는 반응을 남길 수 없어요.', + likeConflictNotice: '이미 좋아요를 누른 게시물이에요.', + dislikeConflictNotice: '이미 싫어요를 누른 게시물이에요.', }; +function getNextReactionType(currentReactionType: 'LIKE' | 'DISLIKE' | null, clickedReactionType: 'LIKE' | 'DISLIKE') { + return currentReactionType === clickedReactionType ? null : clickedReactionType; +} + +interface ReactionOverrideState { + boardId: number | null; + reactionType: 'LIKE' | 'DISLIKE' | null; +} + function parseBoardId(value: string | undefined): number | null { if (!value) return null; @@ -61,8 +73,28 @@ export function BoardDetailPage() { const { mutateAsync: deleteComment, isPending: isDeletingComment } = useDeleteComment(); const { mutateAsync: reactToBoard, isPending: isReacting } = useBoardReaction(); const [reactionError, setReactionError] = useState(''); + const [reactionNotice, setReactionNotice] = useState(''); + const [reactionOverride, setReactionOverride] = useState(null); const canManage = Boolean(board && nickname && board.nickname.trim() === nickname.trim()); + const serverReactionType = getBoardReactionType(board); + const currentReactionType = + reactionOverride?.boardId === boardId ? reactionOverride.reactionType : serverReactionType; + + useEffect(() => { + const timer = + reactionNotice.length > 0 + ? window.setTimeout(() => { + setReactionNotice(''); + }, 5000) + : null; + + return () => { + if (timer !== null) { + window.clearTimeout(timer); + } + }; + }, [reactionNotice]); const handleDelete = async () => { if (boardId === null) return; @@ -166,14 +198,37 @@ export function BoardDetailPage() { }; const handleBoardReaction = async (reactionType: 'LIKE' | 'DISLIKE') => { + if (canManage) { + setReactionError(''); + setReactionNotice(DETAIL_PAGE_COPY.ownReactionNotice); + return; + } + + const previousReactionType = currentReactionType; + const nextReactionType = getNextReactionType(previousReactionType, reactionType); + try { setReactionError(''); + setReactionNotice(''); + setReactionOverride({ boardId, reactionType: nextReactionType }); await reactToBoard({ boardId, nextReactionType: reactionType, - currentReactionType: getBoardReactionType(board), + currentReactionType: previousReactionType, }); } catch (reactionActionError) { + setReactionOverride(null); + const status = getApiErrorStatus(reactionActionError); + + if (status === 409) { + setReactionError(''); + setReactionNotice( + reactionType === 'LIKE' ? DETAIL_PAGE_COPY.likeConflictNotice : DETAIL_PAGE_COPY.dislikeConflictNotice, + ); + void refetch(); + return; + } + setReactionError( getApiErrorMessage( reactionActionError, @@ -186,6 +241,7 @@ export function BoardDetailPage() { return ( + {reactionNotice ? : null} setDeleteDialogOpen(true)} @@ -240,6 +298,16 @@ function PageShell({ children }: { children: ReactNode }) { return <>{children}; } +function ToastMessage({ message }: { message: string }) { + return ( +
+
+ {message} +
+
+ ); +} + function InvalidBoardState() { return (
From e60d56ad9ae2c9e89a325a7e9ebb96d7f09daabd Mon Sep 17 00:00:00 2001 From: sooloin Date: Sun, 14 Jun 2026 23:06:28 +0900 Subject: [PATCH 3/3] =?UTF-8?q?:bug:=20Fix:=20=EC=BB=A4=EB=AE=A4=EB=8B=88?= =?UTF-8?q?=ED=8B=B0=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EB=B0=98=EC=9D=91=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=B2=98=EB=A6=AC=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 게시글 반응 상태를 localStorage 보조값으로 유지하도록 개선 - 상세 API에 반응 타입이 없을 때도 마지막 반응 active 상태가 유지되도록 처리 - 반응 성공 및 취소 시 저장된 반응 상태를 함께 갱신하도록 수정 - 내 게시글 반응 및 중복 반응 상황을 토스트로 안내하도록 유지 - 게시글 반응 관련 mine 쿼리 키를 queryKeys 팩토리로 통일 - 반응 목록 업데이트 헬퍼를 제네릭으로 개선해 재사용성 보완 `#56` --- src/features/community/index.ts | 3 ++ src/features/community/lib/storage.ts | 32 +++++++++++++++++++ .../community/model/useBoardReaction.ts | 18 ++++++----- src/pages/community/BoardDetailPage.tsx | 31 +++++++++++------- src/shared/lib/react-query/queryKey.ts | 1 + 5 files changed, 65 insertions(+), 20 deletions(-) diff --git a/src/features/community/index.ts b/src/features/community/index.ts index c092978..30cfca6 100644 --- a/src/features/community/index.ts +++ b/src/features/community/index.ts @@ -23,8 +23,11 @@ export { } from './lib/constants'; export { getBoardReactionType, getReactionCountDelta, normalizeReactionType } from './lib/reactions'; export { + clearStoredBoardReactionType, clearStoredBoardDraftSessionKey, + getStoredBoardReactionType, getStoredBoardDraftSessionKey, + setStoredBoardReactionType, setStoredBoardDraftSessionKey, } from './lib/storage'; export { INITIAL_BOARD_EDITOR_FORM_STATE, validateBoardEditorForm } from './lib/validation'; diff --git a/src/features/community/lib/storage.ts b/src/features/community/lib/storage.ts index 2fe626a..8650221 100644 --- a/src/features/community/lib/storage.ts +++ b/src/features/community/lib/storage.ts @@ -1,4 +1,7 @@ import { COMMUNITY_DRAFT_SESSION_KEY } from './constants'; +import type { ReactionType } from '../model/types'; + +const COMMUNITY_BOARD_REACTION_KEY_PREFIX = 'community-board-reaction'; export function getStoredBoardDraftSessionKey(): string | null { if (typeof window === 'undefined') { @@ -24,3 +27,32 @@ export function clearStoredBoardDraftSessionKey() { window.localStorage.removeItem(COMMUNITY_DRAFT_SESSION_KEY); } + +function getBoardReactionStorageKey(boardId: number) { + return `${COMMUNITY_BOARD_REACTION_KEY_PREFIX}:${boardId}`; +} + +export function getStoredBoardReactionType(boardId: number): ReactionType | null { + if (typeof window === 'undefined') { + return null; + } + + const value = window.localStorage.getItem(getBoardReactionStorageKey(boardId)); + return value === 'LIKE' || value === 'DISLIKE' ? value : null; +} + +export function setStoredBoardReactionType(boardId: number, reactionType: ReactionType) { + if (typeof window === 'undefined') { + return; + } + + window.localStorage.setItem(getBoardReactionStorageKey(boardId), reactionType); +} + +export function clearStoredBoardReactionType(boardId: number) { + if (typeof window === 'undefined') { + return; + } + + window.localStorage.removeItem(getBoardReactionStorageKey(boardId)); +} diff --git a/src/features/community/model/useBoardReaction.ts b/src/features/community/model/useBoardReaction.ts index 8f0da64..f753788 100644 --- a/src/features/community/model/useBoardReaction.ts +++ b/src/features/community/model/useBoardReaction.ts @@ -2,7 +2,7 @@ import { useMutation, useQueryClient, type InfiniteData } from '@tanstack/react- import { createBoardReaction, deleteBoardReaction, updateBoardReaction } from '../api/reactions'; import { applyReactionToBoardDetail, applyReactionToBoardListItem } from '../lib/reactions'; -import type { BoardDetailResponse, BoardListResponse, MyBoardListResponse, ReactionType } from './types'; +import type { BoardDetailResponse, BoardListItem, BoardListResponse, MyBoardListResponse, ReactionType } from './types'; import { queryKeys } from '@/shared/lib/react-query/queryKey'; interface BoardReactionVariables { @@ -17,12 +17,12 @@ interface BoardReactionContext { previousMineQueries: Array; } -function updateBoardListResponse( - data: BoardListResponse, +function updateBoardListResponse( + data: T, boardId: number, previousReaction: ReactionType | null, nextReaction: ReactionType | null, -) { +): T { return { ...data, boards: data.boards.map((board) => @@ -52,14 +52,16 @@ export function useBoardReaction() { await Promise.all([ queryClient.cancelQueries({ queryKey: queryKeys.boards.detail(boardId) }), queryClient.cancelQueries({ queryKey: queryKeys.boards.listInfinite() }), - queryClient.cancelQueries({ queryKey: ['boards', 'mine'] }), + queryClient.cancelQueries({ queryKey: queryKeys.boards.mineAll() }), ]); const previousDetail = queryClient.getQueryData(queryKeys.boards.detail(boardId)); const previousListInfinite = queryClient.getQueryData>( queryKeys.boards.listInfinite(), ); - const previousMineQueries = queryClient.getQueriesData({ queryKey: ['boards', 'mine'] }); + const previousMineQueries = queryClient.getQueriesData({ + queryKey: queryKeys.boards.mineAll(), + }); queryClient.setQueryData(queryKeys.boards.detail(boardId), (current) => current ? applyReactionToBoardDetail(current, currentReactionType, optimisticReactionType) : current, @@ -76,7 +78,7 @@ export function useBoardReaction() { : current, ); - queryClient.setQueriesData({ queryKey: ['boards', 'mine'] }, (current) => + queryClient.setQueriesData({ queryKey: queryKeys.boards.mineAll() }, (current) => current ? updateBoardListResponse(current, boardId, currentReactionType, optimisticReactionType) : current, ); @@ -107,7 +109,7 @@ export function useBoardReaction() { await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.boards.detail(variables.boardId) }), queryClient.invalidateQueries({ queryKey: queryKeys.boards.listInfinite() }), - queryClient.invalidateQueries({ queryKey: ['boards', 'mine'] }), + queryClient.invalidateQueries({ queryKey: queryKeys.boards.mineAll() }), ]); }, }); diff --git a/src/pages/community/BoardDetailPage.tsx b/src/pages/community/BoardDetailPage.tsx index 271f727..8df49ba 100644 --- a/src/pages/community/BoardDetailPage.tsx +++ b/src/pages/community/BoardDetailPage.tsx @@ -12,7 +12,10 @@ import { COMMENT_MUTATION_STATUS_MESSAGES, CommunityLayout, DeleteBoardDialog, + clearStoredBoardReactionType, getBoardReactionType, + getStoredBoardReactionType, + setStoredBoardReactionType, useBoardDetail, useBoardReaction, useCommentList, @@ -41,11 +44,6 @@ function getNextReactionType(currentReactionType: 'LIKE' | 'DISLIKE' | null, cli return currentReactionType === clickedReactionType ? null : clickedReactionType; } -interface ReactionOverrideState { - boardId: number | null; - reactionType: 'LIKE' | 'DISLIKE' | null; -} - function parseBoardId(value: string | undefined): number | null { if (!value) return null; @@ -74,12 +72,11 @@ export function BoardDetailPage() { const { mutateAsync: reactToBoard, isPending: isReacting } = useBoardReaction(); const [reactionError, setReactionError] = useState(''); const [reactionNotice, setReactionNotice] = useState(''); - const [reactionOverride, setReactionOverride] = useState(null); const canManage = Boolean(board && nickname && board.nickname.trim() === nickname.trim()); const serverReactionType = getBoardReactionType(board); const currentReactionType = - reactionOverride?.boardId === boardId ? reactionOverride.reactionType : serverReactionType; + boardId !== null ? (serverReactionType ?? getStoredBoardReactionType(boardId)) : serverReactionType; useEffect(() => { const timer = @@ -96,6 +93,14 @@ export function BoardDetailPage() { }; }, [reactionNotice]); + useEffect(() => { + if (boardId === null || serverReactionType === null) { + return; + } + + setStoredBoardReactionType(boardId, serverReactionType); + }, [boardId, serverReactionType]); + const handleDelete = async () => { if (boardId === null) return; @@ -204,20 +209,22 @@ export function BoardDetailPage() { return; } - const previousReactionType = currentReactionType; - const nextReactionType = getNextReactionType(previousReactionType, reactionType); + const nextReactionType = getNextReactionType(currentReactionType, reactionType); try { setReactionError(''); setReactionNotice(''); - setReactionOverride({ boardId, reactionType: nextReactionType }); await reactToBoard({ boardId, nextReactionType: reactionType, - currentReactionType: previousReactionType, + currentReactionType, }); + if (nextReactionType === null) { + clearStoredBoardReactionType(boardId); + } else { + setStoredBoardReactionType(boardId, nextReactionType); + } } catch (reactionActionError) { - setReactionOverride(null); const status = getApiErrorStatus(reactionActionError); if (status === 409) { diff --git a/src/shared/lib/react-query/queryKey.ts b/src/shared/lib/react-query/queryKey.ts index f4e9933..caa3208 100644 --- a/src/shared/lib/react-query/queryKey.ts +++ b/src/shared/lib/react-query/queryKey.ts @@ -6,6 +6,7 @@ export const queryKeys = { detail: (boardId: number) => ['boards', boardId, 'detail'] as const, comments: (boardId: number, params?: { page?: number; size?: number }) => ['boards', boardId, 'comments', params?.page ?? 0, params?.size ?? 20] as const, + mineAll: () => ['boards', 'mine'] as const, mine: (params?: { page?: number; size?: number }) => ['boards', 'mine', params?.page ?? 0, params?.size ?? 10] as const, tempSaved: (sessionKey: string) => ['boards', 'temp-save', sessionKey] as const,