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..30cfca6 100644 --- a/src/features/community/index.ts +++ b/src/features/community/index.ts @@ -9,25 +9,32 @@ 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, 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'; 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 +59,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/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/types.ts b/src/features/community/model/types.ts index 8359481..c0cbf3f 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[]; @@ -59,11 +61,29 @@ export interface BoardDetailResponse { imageFileUrls: string[]; profileUrl: string | null; nickname: string; - likeCount?: number; + 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..f753788 --- /dev/null +++ b/src/features/community/model/useBoardReaction.ts @@ -0,0 +1,116 @@ +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, BoardListItem, 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: T, + boardId: number, + previousReaction: ReactionType | null, + nextReaction: ReactionType | null, +): T { + 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: queryKeys.boards.mineAll() }), + ]); + + const previousDetail = queryClient.getQueryData(queryKeys.boards.detail(boardId)); + const previousListInfinite = queryClient.getQueryData>( + queryKeys.boards.listInfinite(), + ); + const previousMineQueries = queryClient.getQueriesData({ + queryKey: queryKeys.boards.mineAll(), + }); + + 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: queryKeys.boards.mineAll() }, (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: queryKeys.boards.mineAll() }), + ]); + }, + }); +} diff --git a/src/features/community/ui/BoardDetailContent.tsx b/src/features/community/ui/BoardDetailContent.tsx index 3d7ff6c..b9a82b1 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,13 @@ interface BoardDetailContentProps { isCreatingComment: boolean; isUpdatingComment: boolean; isDeletingComment: boolean; + displayedLikeCount: number; + displayedDislikeCount: number; + 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; @@ -144,7 +150,13 @@ export function BoardDetailContent({ isCreatingComment, isUpdatingComment, isDeletingComment, + displayedLikeCount, + displayedDislikeCount, + currentReactionType, + isReacting, + reactionErrorMessage, onDelete, + onReact, onRetryComments, onCreateComment, onUpdateComment, @@ -161,7 +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 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 +329,32 @@ export function BoardDetailContent({ {board.boardContent}

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

{reactionErrorMessage}

: null}
@@ -573,6 +606,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 +859,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..8df49ba 100644 --- a/src/pages/community/BoardDetailPage.tsx +++ b/src/pages/community/BoardDetailPage.tsx @@ -1,24 +1,30 @@ 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'; import { BOARD_DETAIL_STATUS_MESSAGES, BOARD_MUTATION_STATUS_MESSAGES, + BOARD_REACTION_STATUS_MESSAGES, BoardDetailContent, COMMENT_LIST_STATUS_MESSAGES, COMMENT_MUTATION_STATUS_MESSAGES, CommunityLayout, DeleteBoardDialog, + clearStoredBoardReactionType, + getBoardReactionType, + getStoredBoardReactionType, + setStoredBoardReactionType, useBoardDetail, + useBoardReaction, useCommentList, useCreateComment, useDeleteBoard, 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 = { @@ -29,8 +35,15 @@ const DETAIL_PAGE_COPY = { loadFailedFallback: '게시글 조회에 실패했어요. 잠시 후 다시 시도해주세요.', retry: '다시 시도', deleteFailedFallback: '게시글을 삭제하지 못했어요. 잠시 후 다시 시도해주세요.', + ownReactionNotice: '내가 작성한 게시물에는 반응을 남길 수 없어요.', + likeConflictNotice: '이미 좋아요를 누른 게시물이에요.', + dislikeConflictNotice: '이미 싫어요를 누른 게시물이에요.', }; +function getNextReactionType(currentReactionType: 'LIKE' | 'DISLIKE' | null, clickedReactionType: 'LIKE' | 'DISLIKE') { + return currentReactionType === clickedReactionType ? null : clickedReactionType; +} + function parseBoardId(value: string | undefined): number | null { if (!value) return null; @@ -56,8 +69,37 @@ 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 [reactionNotice, setReactionNotice] = useState(''); const canManage = Boolean(board && nickname && board.nickname.trim() === nickname.trim()); + const serverReactionType = getBoardReactionType(board); + const currentReactionType = + boardId !== null ? (serverReactionType ?? getStoredBoardReactionType(boardId)) : serverReactionType; + + useEffect(() => { + const timer = + reactionNotice.length > 0 + ? window.setTimeout(() => { + setReactionNotice(''); + }, 5000) + : null; + + return () => { + if (timer !== null) { + window.clearTimeout(timer); + } + }; + }, [reactionNotice]); + + useEffect(() => { + if (boardId === null || serverReactionType === null) { + return; + } + + setStoredBoardReactionType(boardId, serverReactionType); + }, [boardId, serverReactionType]); const handleDelete = async () => { if (boardId === null) return; @@ -160,8 +202,53 @@ export function BoardDetailPage() { } }; + const handleBoardReaction = async (reactionType: 'LIKE' | 'DISLIKE') => { + if (canManage) { + setReactionError(''); + setReactionNotice(DETAIL_PAGE_COPY.ownReactionNotice); + return; + } + + const nextReactionType = getNextReactionType(currentReactionType, reactionType); + + try { + setReactionError(''); + setReactionNotice(''); + await reactToBoard({ + boardId, + nextReactionType: reactionType, + currentReactionType, + }); + if (nextReactionType === null) { + clearStoredBoardReactionType(boardId); + } else { + setStoredBoardReactionType(boardId, nextReactionType); + } + } catch (reactionActionError) { + 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, + '반응을 처리하지 못했어요. 잠시 후 다시 시도해주세요.', + BOARD_REACTION_STATUS_MESSAGES, + ), + ); + } + }; + return ( + {reactionNotice ? : null} setDeleteDialogOpen(true)} + onReact={handleBoardReaction} onRetryComments={() => void commentsQuery.refetch()} onCreateComment={handleCreateComment} onUpdateComment={handleUpdateComment} @@ -212,6 +305,16 @@ function PageShell({ children }: { children: ReactNode }) { return <>{children}; } +function ToastMessage({ message }: { message: string }) { + return ( +
+
+ {message} +
+
+ ); +} + function InvalidBoardState() { return (
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,