Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/features/community/api/reactions.ts
Original file line number Diff line number Diff line change
@@ -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<BoardReactionResponse> {
const response = await apiClient.post<BoardReactionResponse>('/reactions/board', payload);
return response.data;
}

export async function updateBoardReaction(
boardId: number,
payload: UpdateBoardReactionRequest,
): Promise<BoardReactionResponse> {
const response = await apiClient.patch<BoardReactionResponse>(`/reactions/board/${boardId}`, payload);
return response.data;
}

export async function deleteBoardReaction(boardId: number): Promise<BoardReactionResponse> {
const response = await apiClient.delete<BoardReactionResponse>(`/reactions/board/${boardId}`);
return response.data;
}
11 changes: 11 additions & 0 deletions src/features/community/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down
13 changes: 11 additions & 2 deletions src/features/community/lib/constants.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export const COMMUNITY_DRAFT_SESSION_KEY = 'community-board-draft-session-key';

export const BOARD_LIST_STATUS_MESSAGES: Partial<Record<number, string>> = {
400: '잘못된 요청이에요.',
400: '게시글 목록 요청이 올바르지 않아요.',
401: '로그인이 필요해요. 다시 로그인해주세요.',
500: '서버 오류가 발생했어요. 잠시 후 다시 시도해주세요.',
};
Expand All @@ -13,8 +13,17 @@ export const BOARD_MUTATION_STATUS_MESSAGES: Partial<Record<number, string>> = {
500: '서버 오류가 발생했어요. 잠시 후 다시 시도해주세요.',
};

export const BOARD_REACTION_STATUS_MESSAGES: Partial<Record<number, string>> = {
400: '반응 요청이 올바르지 않아요. 다시 시도해주세요.',
401: '로그인이 필요한 기능이에요. 다시 로그인해주세요.',
403: '이 게시물에 반응할 권한이 없어요.',
404: '게시물을 찾을 수 없거나 남긴 반응이 없어요.',
409: '이미 같은 반응을 남긴 게시물이에요.',
500: '서버 오류가 발생했어요. 잠시 후 다시 시도해주세요.',
};

export const BOARD_DETAIL_STATUS_MESSAGES: Partial<Record<number, string>> = {
400: '잘못된 게시글 요청이에요.',
400: '게시글 요청이 올바르지 않아요.',
401: '로그인이 필요해요. 다시 로그인해주세요.',
403: '게시글을 조회할 권한이 없어요.',
404: '게시글을 찾을 수 없어요.',
Expand Down
68 changes: 68 additions & 0 deletions src/features/community/lib/reactions.ts
Original file line number Diff line number Diff line change
@@ -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<BoardDetailResponse> | 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),
};
}
32 changes: 32 additions & 0 deletions src/features/community/lib/storage.ts
Original file line number Diff line number Diff line change
@@ -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') {
Expand All @@ -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));
}
22 changes: 21 additions & 1 deletion src/features/community/model/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export interface BoardListItem {
modifiedAt: string;
}

export type ReactionType = 'LIKE' | 'DISLIKE';

export interface BoardListResponse {
message: string;
boards: BoardListItem[];
Expand Down Expand Up @@ -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;
Expand Down
116 changes: 116 additions & 0 deletions src/features/community/model/useBoardReaction.ts
Original file line number Diff line number Diff line change
@@ -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<BoardListResponse, number>;
previousMineQueries: Array<readonly [readonly unknown[], MyBoardListResponse | undefined]>;
}

function updateBoardListResponse<T extends { boards: BoardListItem[] }>(
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<BoardReactionContext> => {
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<BoardDetailResponse>(queryKeys.boards.detail(boardId));
const previousListInfinite = queryClient.getQueryData<InfiniteData<BoardListResponse, number>>(
queryKeys.boards.listInfinite(),
);
const previousMineQueries = queryClient.getQueriesData<MyBoardListResponse>({
queryKey: queryKeys.boards.mineAll(),
});

queryClient.setQueryData<BoardDetailResponse>(queryKeys.boards.detail(boardId), (current) =>
current ? applyReactionToBoardDetail(current, currentReactionType, optimisticReactionType) : current,
);

queryClient.setQueryData<InfiniteData<BoardListResponse, number>>(queryKeys.boards.listInfinite(), (current) =>
current
? {
...current,
pages: current.pages.map((page) =>
updateBoardListResponse(page, boardId, currentReactionType, optimisticReactionType),
),
}
: current,
);

queryClient.setQueriesData<MyBoardListResponse>({ 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() }),
]);
},
});
}
Loading
Loading