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 (