From 76608aeb463cf7f5eb55299da9a8228acfc1970e Mon Sep 17 00:00:00 2001 From: Cho-heejung <66050038+he2e2@users.noreply.github.com> Date: Thu, 5 Dec 2024 15:04:19 +0900 Subject: [PATCH] =?UTF-8?q?[Feat]=20=EC=95=84=EC=B9=B4=EC=9D=B4=EB=B8=8C?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EC=97=B0=EA=B2=B0=20(#88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Color type 대문자로 변경 * feat: 검색 페이지 변경 및 API 연결 * feat: 이미지 업로드 API 구현 * feat: 이미지 업로드 시 API 연결 * feat: 아카이브 전체 조회 및 검색 연결 * fix: useCustomInfiniteQuery 수정 * feat: 대기 화면 * feat: SearchPage 검색 -> SearchBar 검색 * feat: 드래그 앤 드롭 * feat: Loader * feat: 마이 페이지 쪽 아카이브 hook 구현 * feat: 아카이브 상세 페이지 연결 * feat: 나의 아카이브 조회 * feat: error handling with toast message * feat: 아카이브 생성 에러 메시지 받아서 출력 * feat: 아카이브 댓글 수정 * feat: 아카이브 순서 변경 * feat: 내 아카이브 하트 제거 * feat: 컬러 선택 표시 변경 * fix: 이미지 업로드 후, 전역 상태에 저장 로직 변경 * fix: 토스트메시지 출력 타이밍 변경 * fix: mac tag 중복 입력 문제 * feat: 댓글 optimistic update * feat: 아카이브 순서 변경 * feat: 좋아요 optimistic update 로직 수정 * feat: header 검색 바 리팩토링 * feat: 리스트 loading -> pending * fix: build error --- src/app/QueryProvider.tsx | 9 +- src/app/main.tsx | 6 +- src/app/styles/globals.scss | 11 +- src/features/archive/archive.api.ts | 22 +- src/features/archive/archive.dto.ts | 49 ++- src/features/archive/archive.hook.ts | 299 ++++++++++++++---- src/features/archive/colors.type.ts | 29 +- src/features/archive/editor.hook.ts | 42 ++- src/features/archive/model/archive.store.ts | 12 +- .../archive/ui/ArchiveCard.module.scss | 10 +- src/features/archive/ui/ArchiveCard.tsx | 40 ++- src/features/archive/ui/ColorChip.module.scss | 19 +- src/features/archive/ui/ColorChip.tsx | 10 +- src/features/archive/ui/ColorSelect.tsx | 12 +- .../archive/ui/CommentItem.module.scss | 86 ++++- src/features/archive/ui/CommentItem.tsx | 70 +++- .../archive/ui/MarkdownPreview.module.scss | 8 + src/features/archive/ui/WriteComment.tsx | 8 +- src/features/gathering/api/gathering.hook.ts | 1 + src/features/image/image.api.ts | 12 + src/features/image/image.dto.ts | 7 + src/features/image/image.hook.ts | 8 + src/features/index.ts | 1 - src/features/search/ui/SearchBar.module.scss | 12 +- src/features/search/ui/SearchBar.tsx | 43 ++- src/features/user/ui/UserProfileInfo.tsx | 7 +- src/mocks/handlers/archive.ts | 8 +- src/pages/ArchiveListPage/ArchiveListPage.tsx | 8 +- .../DetailArchivePage.module.scss | 7 +- .../DetailArchivePage/DetailArchivePage.tsx | 54 ++-- .../LikeListPage/LikeListPage.module.scss | 2 +- src/pages/MyPage/MyPage.module.scss | 10 + src/pages/SearchPage/SearchPage.module.scss | 16 +- src/pages/SearchPage/SearchPage.tsx | 32 +- .../WriteArchivePage/WriteArchivePage.tsx | 2 +- src/shared/api/baseApi.ts | 5 + src/shared/hook/useCustomInfiniteQuery.ts | 68 ++-- src/shared/ui/Loader/Loader.module.scss | 54 ++++ src/shared/ui/Loader/Loader.tsx | 13 + .../ui/MarkdownEditor/MarkdownEditor.tsx | 2 +- src/shared/ui/NoResult/NoResult.tsx | 9 + src/shared/ui/Tag/Tag.tsx | 8 +- src/shared/ui/index.ts | 2 + src/shared/util/findCategoryName.ts | 14 + src/shared/util/index.ts | 1 + src/widgets/ArchiveGrid/ArchiveGrid.tsx | 26 +- src/widgets/ChattingModal/ChattingModal.tsx | 2 +- .../ChattingModal/ui/ChattingModal.tsx | 8 +- .../DetailArchive/DetailHeader.module.scss | 8 + src/widgets/DetailArchive/DetailHeader.tsx | 49 +-- src/widgets/Layout/ui/Footer/Footer.tsx | 2 +- .../Layout/ui/Header/Header.module.scss | 18 ++ src/widgets/Layout/ui/Header/Header.tsx | 114 +++++-- src/widgets/LikeTab/LikeTab.tsx | 52 +-- src/widgets/MenuModal/MenuModal.tsx | 40 ++- src/widgets/SearchAll/SearchAll.module.scss | 40 --- src/widgets/SearchAll/SearchAll.tsx | 70 ---- src/widgets/SearchTap/SearchTap.module.scss | 47 --- src/widgets/SearchTap/SearchTap.tsx | 79 ----- .../SettingUser/SetArchive.module.scss | 21 ++ src/widgets/SettingUser/SetArchive.tsx | 104 +++++- src/widgets/SettingUser/SideTab.module.scss | 6 + src/widgets/SettingUser/SideTab.tsx | 18 +- src/widgets/UserContents/UserContents.tsx | 39 +-- src/widgets/WriteArchive/ColorChips.tsx | 4 +- src/widgets/WriteArchive/ColorChoiceStep.tsx | 4 +- src/widgets/WriteArchive/WriteStep.tsx | 37 ++- src/widgets/index.ts | 2 - 68 files changed, 1300 insertions(+), 638 deletions(-) create mode 100644 src/features/image/image.api.ts create mode 100644 src/features/image/image.dto.ts create mode 100644 src/features/image/image.hook.ts create mode 100644 src/shared/ui/Loader/Loader.module.scss create mode 100644 src/shared/ui/Loader/Loader.tsx create mode 100644 src/shared/ui/NoResult/NoResult.tsx create mode 100644 src/shared/util/findCategoryName.ts create mode 100644 src/shared/util/index.ts delete mode 100644 src/widgets/SearchAll/SearchAll.module.scss delete mode 100644 src/widgets/SearchAll/SearchAll.tsx delete mode 100644 src/widgets/SearchTap/SearchTap.module.scss delete mode 100644 src/widgets/SearchTap/SearchTap.tsx create mode 100644 src/widgets/SettingUser/SetArchive.module.scss diff --git a/src/app/QueryProvider.tsx b/src/app/QueryProvider.tsx index 827ba39..fb90fdb 100644 --- a/src/app/QueryProvider.tsx +++ b/src/app/QueryProvider.tsx @@ -1,17 +1,22 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; const QueryProvider = ({ children }: { children: React.ReactNode }) => { const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 0, - refetchOnWindowFocus: false, retry: 1, }, }, }); - return {children}; + return ( + + {children} + + + ); }; export default QueryProvider; diff --git a/src/app/main.tsx b/src/app/main.tsx index 8eab5c0..781f5d6 100644 --- a/src/app/main.tsx +++ b/src/app/main.tsx @@ -6,9 +6,9 @@ import { worker } from '../mocks/browser'; import './styles/globals.scss'; -if (process.env.NODE_ENV === 'development') { - void worker.start({ onUnhandledRequest: 'warn' }); -} +// if (process.env.NODE_ENV === 'development') { +// void worker.start({ onUnhandledRequest: 'warn' }); +// } createRoot(document.getElementById('root')!).render( diff --git a/src/app/styles/globals.scss b/src/app/styles/globals.scss index 3ad3ba1..14f9b94 100644 --- a/src/app/styles/globals.scss +++ b/src/app/styles/globals.scss @@ -52,8 +52,12 @@ // 토스트 메시지 스타일링 .swal2-toast { font-family: 'Pretendard Variable', Pretendard, sans-serif !important; - background-color: rgba(variables.$secondary-color, 0.95) !important; + background-color: rgba(variables.$secondary-color, 1) !important; box-shadow: 0 0 1rem rgba(variables.$primary-color, 0.1) !important; + + .swal2-container.swal2-backdrop-show { + background-color: transparent !important; + } } // 아이콘 색상 커스텀 @@ -98,11 +102,6 @@ } } -// 오버레이 배경 커스텀 -.swal2-container.swal2-backdrop-show { - background-color: variables.$modal-bg !important; -} - // 프로그레스 바 커스텀 (토스트용) .swal2-timer-progress-bar { background-color: rgba(variables.$primary-color, 0.2) !important; diff --git a/src/features/archive/archive.api.ts b/src/features/archive/archive.api.ts index f96f2ec..f65a12e 100644 --- a/src/features/archive/archive.api.ts +++ b/src/features/archive/archive.api.ts @@ -1,4 +1,4 @@ -import type { PostCommentApiResponse } from './archive.dto'; +import type { PatchArchiveOrderDTO, PostCommentApiResponse } from './archive.dto'; import type { GetArchiveApiResponse, GetCommentsApiResponse } from './archive.dto'; import type { PostArchiveApiResponse, @@ -36,13 +36,18 @@ export const postCreateComment = (archiveId: number, content: string) => export const deleteComment = (commentId: number) => api.delete(`/archive/comment/${commentId}`).then(res => res.data); -export const getPopularlityArchiveList = () => +export const putComment = (commentId: number, content: string) => + api + .put(`/archive/comment/${commentId}`, { content }) + .then(res => res.data); + +export const getPopularlityArchiveList = (size: number) => api .get('/archive', { params: { sort: 'popularlity', page: 0, - size: 5, + size, }, }) .then(res => res.data); @@ -72,5 +77,12 @@ export const getSearchArchive = (searchKeyword: string, page: number) => export const postLikeArchive = (archiveId: number) => api.post(`/archive/${archiveId}`); -export const getLikeArchiveList = () => - api.get('/archive/me/like').then(res => res.data); +export const getLikeArchiveList = (page: number) => + api + .get('/archive/me/like', { params: { page, size: 9 } }) + .then(res => res.data); + +export const getMyArchiveList = () => + api.get('/archive/me').then(res => res.data); + +export const patchArchiveOrder = (data: PatchArchiveOrderDTO) => api.patch('/archive', data); diff --git a/src/features/archive/archive.dto.ts b/src/features/archive/archive.dto.ts index 06b4be4..9489cba 100644 --- a/src/features/archive/archive.dto.ts +++ b/src/features/archive/archive.dto.ts @@ -6,9 +6,9 @@ export interface BaseArchiveDTO { title: string; description: string; introduction: string; - type: Color; + colorType: Color; canComment: boolean; - tags: { content: string }[]; + tags: { tag: string }[]; imageUrls: { url: string }[]; } @@ -20,6 +20,7 @@ export interface Archive extends BaseArchiveDTO { hits: number; isMine: boolean; userProfile: string; + type: Color; } export interface ArchiveCardDTO { @@ -29,8 +30,8 @@ export interface ArchiveCardDTO { type: Color; likeCount: number; username: string; - thumbnail: string; - createDate: Date; + imageUrl: string; + createDate: string; isLiked: boolean; } @@ -50,8 +51,46 @@ export interface PostArchiveResponseDTO { archiveId: number; } +export interface PatchArchiveOrderDTO { + orderRequest: Record; +} + +export type Meta = { + currentPage: number; + size: number; + hasNext: boolean; +}; + +export type CommentPageData = { + comments: Comment[]; + meta: Meta; +}; + +export type ArchivePageData = { + archives: ArchiveCardDTO[]; + meta: Meta; +}; + +export type Page = { + data: T; + timeStamp: string; +}; + +export type CommentsPageDTO = { + pages: Page[]; + pageParams: number[]; +}; + +export type ArchivePageDTO = { + pages: Page[]; + pageParams: number[]; +}; + export type PostArchiveApiResponse = ApiResponse; export type GetArchiveApiResponse = ApiResponse; export type GetCommentsApiResponse = ApiResponse; export type PostCommentApiResponse = ApiResponse; -export type GetArchiveListApiResponse = ApiResponse; +export type GetArchiveListApiResponse = ApiResponse<{ + archives: ArchiveCardDTO[]; + slice: Meta; +}>; diff --git a/src/features/archive/archive.hook.ts b/src/features/archive/archive.hook.ts index 2887821..1878419 100644 --- a/src/features/archive/archive.hook.ts +++ b/src/features/archive/archive.hook.ts @@ -1,4 +1,5 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { AxiosError } from 'axios'; import { deleteArchive, @@ -12,6 +13,10 @@ import { getPopularlityArchiveList, postLikeArchive, getLikeArchiveList, + getSearchArchive, + putComment, + getMyArchiveList, + patchArchiveOrder, } from './archive.api'; import type { BaseArchiveDTO, @@ -19,25 +24,37 @@ import type { GetCommentsApiResponse, GetArchiveListApiResponse, ArchiveCardDTO, + PatchArchiveOrderDTO, + CommentsPageDTO, + ArchivePageDTO, } from './archive.dto'; import type { Color } from './colors.type'; import { useCustomInfiniteQuery } from '@/shared/hook'; +import { customToast } from '@/shared/ui'; export const useCreateArchive = () => useMutation({ mutationFn: (data: BaseArchiveDTO) => postCreateArchive(data), + onError: async (err: AxiosError<{ status: number; reason: string; timeStamp: string }>) => { + const message = err.response?.data?.reason || '아카이브 작성에 실패하였습니다.'; + await customToast({ text: message, timer: 3000, icon: 'error' }); + }, }); export const useUpdateArchive = (archiveId: number) => useMutation({ mutationFn: (data: BaseArchiveDTO) => putArchive(archiveId, data), + onError: async () => { + await customToast({ text: '아카이브 수정에 실패하였습니다', timer: 3000, icon: 'error' }); + }, }); -export const useDeleteArchive = () => - useMutation({ +export const useDeleteArchive = () => { + return useMutation({ mutationFn: ({ archiveId }: { archiveId: number }) => deleteArchive(archiveId), }); +}; export const useArchive = (archiveId: number) => useQuery({ @@ -50,6 +67,7 @@ export const useComments = (archiveId: number) => { ['/archive', archiveId, 'comment'], ({ pageParam }) => getComments(archiveId, 10, pageParam), 10, + 'comments', ); }; @@ -80,19 +98,104 @@ export const useCreateComment = (archiveId: number) => { userProfile: userProfile, }; - queryClient.setQueryData( - ['/archive', archiveId, 'comment'], - (old: GetCommentsApiResponse) => { - if (!old.data) return old; + queryClient.setQueryData(['/archive', archiveId, 'comment'], (old: CommentsPageDTO) => { + if (!old) return old; - return [...old.data, optimisticComment]; - }, - ); + const updatedPages = old.pages.map((page, index) => { + if (index === 0) { + return { + ...page, + data: { + ...page.data, + comments: [optimisticComment, ...page.data.comments], + }, + }; + } + return page; + }); + + return { + ...old, + pages: updatedPages, + }; + }); + + return { previousComments, optimisticComment }; + }, + onSuccess: async (newComment, _, context) => { + queryClient.setQueryData(['/archive', archiveId, 'comment'], (old: CommentsPageDTO) => { + if (!old) return old; + + const updatedPages = old.pages.map(page => ({ + ...page, + data: { + ...page.data, + comments: page.data.comments.map(comment => + comment.commentId === context?.optimisticComment.commentId + ? { ...comment, commentId: newComment.data?.commentId } + : comment, + ), + }, + })); + + return { + ...old, + pages: updatedPages, + }; + }); + + await customToast({ text: '댓글이 작성되었습니다.', timer: 3000, icon: 'success' }); + }, + onError: async (err, _, context) => { + await customToast({ text: '댓글 작성에 실패하였습니다', timer: 3000, icon: 'error' }); + if (context) { + queryClient.setQueryData(['/archive', archiveId, 'comment'], context.previousComments); + } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ['/archive', archiveId, 'comment'] }).catch(err => { + console.error('Failed to invalidate queries:', err); + }); + }, + }); +}; + +export const useUpdateComment = (archiveId: number, commentId: number, content: string) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ content }: { content: string }) => putComment(commentId, content), + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: ['/archive', archiveId, 'comment'] }); + + const previousComments = queryClient.getQueryData(['/archive', archiveId, 'comment']); + + queryClient.setQueryData(['/archive', archiveId, 'comment'], (old: CommentsPageDTO) => { + if (!old) return old; + + const updatedPages = old.pages.map(page => ({ + ...page, + data: { + ...page.data, + comments: page.data.comments.map((comment: Comment) => + comment.commentId === commentId ? { ...comment, content } : comment, + ), + }, + })); + + return { + ...old, + pages: updatedPages, + }; + }); return { previousComments }; }, - onError: (err, _, context) => { - console.log(err); + onSuccess: async () => { + await customToast({ text: '댓글이 수정되었습니다', timer: 3000, icon: 'success' }); + }, + onError: async (err, _, context) => { + await customToast({ text: '댓글 수정에 실패하였습니다', timer: 3000, icon: 'error' }); if (context) { queryClient.setQueryData(['/archive', archiveId, 'comment'], context.previousComments); } @@ -116,19 +219,32 @@ export const useDeleteComment = (archiveId: number) => { const previousComments = queryClient.getQueryData(['/archive', archiveId, 'comment']); - queryClient.setQueryData( - ['/archive', archiveId, 'comment'], - (old: GetCommentsApiResponse) => { - if (!old.data) return old; + queryClient.setQueryData(['/archive', archiveId, 'comment'], (old: CommentsPageDTO) => { + if (!old) return old; - return old.data.filter((comment: Comment) => comment.commentId !== commentId); - }, - ); + const updatedPages = old.pages.map(page => ({ + ...page, + data: { + ...page.data, + comments: page.data.comments.filter( + (comment: Comment) => comment.commentId !== commentId, + ), + }, + })); + + return { + ...old, + pages: updatedPages, + }; + }); return { previousComments }; }, - onError: (err, _, context) => { - console.log(err); + onSuccess: async () => { + await customToast({ text: '댓글이 삭제되었습니다', timer: 3000, icon: 'success' }); + }, + onError: async (err, _, context) => { + await customToast({ text: '댓글 삭제에 실패하였습니다', timer: 3000, icon: 'error' }); if (context) { queryClient.setQueryData(['/archive', archiveId, 'comment'], context.previousComments); } @@ -141,76 +257,151 @@ export const useDeleteComment = (archiveId: number) => { }); }; -export const usePopularArchiveList = () => +export const usePopularArchiveList = (size: number) => useQuery({ queryKey: ['/archive', 'popularlity'], - queryFn: () => getPopularlityArchiveList(), + queryFn: () => getPopularlityArchiveList(size), }); -export const useArchiveList = (sort: string, color: Color | 'default') => { +export const useArchiveList = (sort: string, color: Color) => { return useCustomInfiniteQuery( - ['/archive', sort, color], - ({ pageParam }) => getArchiveList(sort, pageParam, color === 'default' ? null : color), + ['/archive', 'list', sort, color], + ({ pageParam }) => getArchiveList(sort, pageParam, color === 'DEFAULT' ? null : color), 9, + 'archives', + true, ); }; export const useSearchArchive = (searchKeyword: string) => { return useCustomInfiniteQuery( - ['/archive', 'search', searchKeyword], - ({ pageParam }) => getArchiveList(searchKeyword, pageParam), + ['/archive', 'list', 'search', searchKeyword], + ({ pageParam }) => getSearchArchive(searchKeyword, pageParam), 9, + 'archives', + true, ); }; -export const useLikeArchiveList = () => - useQuery({ - queryKey: ['/archive/me/like'], - queryFn: getLikeArchiveList, - }); +export const useLikeArchiveList = () => { + return useCustomInfiniteQuery( + ['/archive', 'list', 'me', 'like'], + ({ pageParam }) => getLikeArchiveList(pageParam), + 9, + 'archives', + true, + ); +}; export const useLikeArchive = (archiveId: number) => { const queryClient = useQueryClient(); return useMutation({ mutationFn: () => postLikeArchive(archiveId), - onMutate: () => { - const prevData = queryClient - .getQueryCache() - .findAll({ predicate: query => query.queryKey[0] === '/archive' }); - - prevData.forEach(query => { - queryClient.setQueryData(query.queryKey, (oldData: GetArchiveListApiResponse) => { - if (!oldData.data) return oldData; - - return oldData.data.map((archive: ArchiveCardDTO) => - archive.archiveId === archiveId - ? { - ...archive, - isLiked: !archive.isLiked, + onMutate: async () => { + // TODO : isLiked optimistic update + await queryClient.cancelQueries({ queryKey: ['/archive', 'list', 'me', 'like'] }); + + const prevArchiveList = queryClient.getQueryData(['/archive', 'list', 'me', 'like']); + const prevLikedData = queryClient.getQueryCache().findAll({ + predicate: query => query.queryKey[0] === '/archive' && query.queryKey[1] === 'list', + }); + + queryClient.setQueryData(['/archive', 'list', 'me', 'like'], (old: ArchivePageDTO) => { + if (!old) return old; + + const updatedPages = old.pages.map(page => ({ + ...page, + data: { + ...page.data, + archives: page.data.archives.filter( + (archive: ArchiveCardDTO) => archive.archiveId !== archiveId, + ), + }, + })); + + return { + ...old, + pages: updatedPages, + }; + }); + + prevLikedData.forEach(query => { + queryClient.setQueryData(query.queryKey, (old: ArchivePageDTO) => { + if (!old) return old; + + const updatedPages = old.pages.map(page => ({ + ...page, + data: { + ...page.data, + archives: page.data.archives.map((archive: ArchiveCardDTO) => { + if (archive.archiveId === archiveId) { + return { + ...archive, + isLiked: !archive.isLiked, + }; } - : archive, - ); + return archive; + }), + }, + })); + + return { + ...old, + pages: updatedPages, + }; }); }); - return { prevData }; + return { prevArchiveList, prevLikedData }; }, - onError: (_, __, context) => { + onError: (err, _, context) => { if (context) { - context.prevData.forEach(query => { + queryClient.setQueryData(['/archive', archiveId, 'comment'], context.prevArchiveList); + context.prevLikedData.forEach(query => { queryClient.invalidateQueries({ queryKey: query.queryKey }).catch(err => { console.error('Failed to invalidate query:', err); }); }); } }, - onSettled: () => { - queryClient - .invalidateQueries({ predicate: query => query.queryKey[0] === '/archive' }) + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['/archive', 'list', 'me', 'like'] }); + await queryClient + .invalidateQueries({ + predicate: query => query.queryKey[0] === '/archive' && query.queryKey[1] === 'list', + }) .catch(err => { console.error('Failed to invalidate queries:', err); }); }, }); }; + +export const useMyArchiveList = () => + useQuery({ + queryKey: ['/archive', 'list', 'me'], + queryFn: getMyArchiveList, + }); + +export const useUpdateArchiveOrder = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: PatchArchiveOrderDTO) => patchArchiveOrder(data), + onSuccess: async () => { + await customToast({ text: '아카이브 순서가 변경되었습니다', timer: 3000, icon: 'success' }); + + queryClient.invalidateQueries({ queryKey: ['/archive', 'list', 'me'] }).catch(error => { + console.error('Error invalidating queries:', error); + }); + }, + onError: async () => { + await customToast({ + text: '아카이브 순서 변경에 실패하였습니다', + timer: 3000, + icon: 'error', + }); + }, + }); +}; diff --git a/src/features/archive/colors.type.ts b/src/features/archive/colors.type.ts index e68e088..11191ca 100644 --- a/src/features/archive/colors.type.ts +++ b/src/features/archive/colors.type.ts @@ -1,6 +1,7 @@ -export type Color = 'red' | 'yellow' | 'blue' | 'green' | 'purple'; +export type Color = 'RED' | 'YELLOW' | 'BLUE' | 'GREEN' | 'PURPLE' | 'DEFAULT'; export interface ColorInfo { + label: string; name: string; description: string; tag: string; @@ -15,43 +16,55 @@ export interface ColorGroup { export const ColorData: ColorGroup[] = [ { group: 'one', - colors: ['red', 'yellow', 'blue'], + colors: ['RED', 'YELLOW', 'BLUE'], }, { group: 'two', - colors: ['green', 'purple'], + colors: ['GREEN', 'PURPLE'], }, ]; export const ColorMap: { [key in Color]: ColorInfo } = { - red: { + RED: { + label: 'red', name: '빨강', description: '추진력이 돋보이는 기록', tag: '#주도적인 리더십 #적극적인 행동력', hex: '#ff5e5e', }, - yellow: { + YELLOW: { + label: 'yellow', name: '노랑', description: '창의력이 돋보이는 기록', tag: '#창의적인 아이디어 #자유로운 발상', hex: '#ffe66b', }, - blue: { + BLUE: { + label: 'blue', name: '파랑', description: '분석력이 돋보이는 기록', tag: '#논리적 사고 #깊이 있는 연구', hex: '#8ad0e2', }, - green: { + GREEN: { + label: 'green', name: '초록', description: '헌신했던 경험이 돋보이는 기록', tag: '#타인을 위한 봉사 #공동체 기여', hex: '#b5d681', }, - purple: { + PURPLE: { + label: 'purple', name: '보라', description: '성찰력이 돋보이는 기록', tag: '#내적 동기 탐구 #가치관 형성', hex: '#aa8abd', }, + DEFAULT: { + label: 'default', + name: '기본', + description: '기본 색상', + tag: '#기본 색상', + hex: '#333533', + }, }; diff --git a/src/features/archive/editor.hook.ts b/src/features/archive/editor.hook.ts index ae494c3..9b71f1c 100644 --- a/src/features/archive/editor.hook.ts +++ b/src/features/archive/editor.hook.ts @@ -3,6 +3,10 @@ import { EditorView } from '@codemirror/view'; import { useCallback } from 'react'; import type { EditorViewRef } from './editor.type'; +import { useArchiveStore } from './model'; +import type { PostImagesApiResponse } from '../image/image.dto'; + +import api from '@/shared/api/baseApi'; export const useMarkdown = ({ editorViewRef, @@ -82,18 +86,34 @@ export const useMarkdown = ({ }, []); const handleImage = useCallback( - (imageFile: File, view: EditorView) => { + async (imageFile: File, view: EditorView) => { if (!/image\/(png|jpg|jpeg|gif)/.test(imageFile.type)) return; const formData = new FormData(); - formData.append('image', imageFile); - - // TODO: 서버 API 호출 후 이미지 URL 반환 - console.log('Uploading image:', imageFile); - - // Mock image URL - const mockImageUrl = 'https://example.com/uploaded-image.jpg'; - insertImageAtCursor(view, `![Image](${mockImageUrl})`); + formData.append('files', imageFile); + const { archiveData, updateArchiveData } = useArchiveStore.getState(); + + try { + const imageUrls = await api + .post('/upload/images', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }) + .then(res => { + updateArchiveData('imageUrls', [ + ...archiveData.imageUrls, + { url: res.data.data?.imgUrls[0].imgUrl ?? '' }, + ]); + + return res.data.data?.imgUrls; + }); + + insertImageAtCursor(view, `![Image](${imageUrls?.[0].imgUrl})`); + } catch { + console.error('Failed to upload image'); + return; + } }, [insertImageAtCursor], ); @@ -108,7 +128,7 @@ export const useMarkdown = ({ if (item.kind === 'file') { const file = item.getAsFile(); if (file) { - handleImage(file, view); + handleImage(file, view).catch(console.error); } } } @@ -122,7 +142,7 @@ export const useMarkdown = ({ const { files } = event.dataTransfer; - for (const file of files) handleImage(file, view); + for (const file of files) handleImage(file, view).catch(console.error); }, }); diff --git a/src/features/archive/model/archive.store.ts b/src/features/archive/model/archive.store.ts index 453daf5..372611c 100644 --- a/src/features/archive/model/archive.store.ts +++ b/src/features/archive/model/archive.store.ts @@ -12,7 +12,7 @@ interface ArchiveStore { setArchiveData: (newData: BaseArchiveDTO) => void; updateArchiveData: (key: T, value: BaseArchiveDTO[T]) => void; resetArchiveData: () => void; - color: Color | null; + color: Color; setColor: (color: Color) => void; clearStorage: () => void; } @@ -21,10 +21,10 @@ export const initialArchiveState: BaseArchiveDTO = { title: '', description: '', introduction: '', - type: 'red', + colorType: 'DEFAULT', canComment: false, tags: [], - imageUrls: [{ url: 'https://source.unsplash.com/random/800x600' }], + imageUrls: [], }; export const useArchiveStore = create( @@ -58,11 +58,11 @@ export const useArchiveStore = create( })); }, - color: null, + color: 'DEFAULT', setColor: color => { set( produce((state: ArchiveStore) => { - state.archiveData.type = color; + state.archiveData.colorType = color; }), ); set(() => ({ @@ -74,7 +74,7 @@ export const useArchiveStore = create( set(() => ({ archiveId: 0, archiveData: initialArchiveState, - color: null, + color: 'DEFAULT', })); useArchiveStore.persist.clearStorage(); }, diff --git a/src/features/archive/ui/ArchiveCard.module.scss b/src/features/archive/ui/ArchiveCard.module.scss index aa34c9f..d152acd 100644 --- a/src/features/archive/ui/ArchiveCard.module.scss +++ b/src/features/archive/ui/ArchiveCard.module.scss @@ -1,13 +1,5 @@ @use 'sass:color'; -.wrapper { - display: flex; - flex-direction: column; - gap: 0.8rem; - width: 20rem; - padding: 1.8rem; -} - .info { z-index: 10; display: flex; @@ -147,7 +139,7 @@ position: absolute; right: 1.6rem; bottom: 1.6rem; - z-index: 30; + z-index: 100; font-size: 1.6rem; color: rgb(255, 136, 130); text-shadow: '0 0 2px black'; diff --git a/src/features/archive/ui/ArchiveCard.tsx b/src/features/archive/ui/ArchiveCard.tsx index 98abe5b..0121e39 100644 --- a/src/features/archive/ui/ArchiveCard.tsx +++ b/src/features/archive/ui/ArchiveCard.tsx @@ -1,35 +1,53 @@ import cn from 'classnames'; +import { useNavigate } from 'react-router-dom'; import type { ArchiveCardDTO } from '../archive.dto'; import styles from './ArchiveCard.module.scss'; +import { useLikeArchive } from '@/features'; import ColorChipLogo from '@/shared/assets/color-chip-logo.svg'; import EmptyHeart from '@/shared/assets/empty-heart.svg'; import Heart from '@/shared/assets/heart.svg'; -export const ArchiveCard = ({ archive }: { archive: ArchiveCardDTO }) => { +export const ArchiveCard = ({ archive, isMine }: { archive: ArchiveCardDTO; isMine?: boolean }) => { + const { mutate: likeArchive } = useLikeArchive(archive.archiveId); + const navigate = useNavigate(); + return ( -
+
archive-thumbnail {
} -
+
{ + navigate(`/archive/${archive.archiveId}`); + }} + >

{archive.title}

{archive.username}

{archive.introduction}
-

{archive.createDate.toISOString().slice(0, 10)}

- isLiked-icon +

{archive.createDate}

+ {!isMine && ( + + )}
); diff --git a/src/features/archive/ui/ColorChip.module.scss b/src/features/archive/ui/ColorChip.module.scss index 63f227a..d406fc4 100644 --- a/src/features/archive/ui/ColorChip.module.scss +++ b/src/features/archive/ui/ColorChip.module.scss @@ -13,6 +13,7 @@ } .chip { + position: relative; display: flex; align-items: center; align-self: stretch; @@ -25,10 +26,22 @@ height: 7.375rem; object-fit: contain; } -} -.selected { - background-color: rgba(216, 216, 216, 50%); + .check { + position: absolute; + right: 0.75rem; + bottom: 0.75rem; + color: $secondary-color; + text-shadow: 0 0 0.125rem rgba(0, 0, 0, 25%); + visibility: hidden; + opacity: 0; + transition: opacity 0.3s ease; + } + + .selected { + visibility: visible; + opacity: 1; + } } .description { diff --git a/src/features/archive/ui/ColorChip.tsx b/src/features/archive/ui/ColorChip.tsx index bf76675..32e59ac 100644 --- a/src/features/archive/ui/ColorChip.tsx +++ b/src/features/archive/ui/ColorChip.tsx @@ -1,3 +1,7 @@ +import { faCircleCheck } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import cn from 'classnames'; + import type { ColorInfo } from '../colors.type'; import styles from './ColorChip.module.scss'; @@ -16,9 +20,13 @@ export const ColorChip = ({
color-chip-logo +
-

{colors.name}

+

{colors.name}

{colors.description}

{colors.tag}

diff --git a/src/features/archive/ui/ColorSelect.tsx b/src/features/archive/ui/ColorSelect.tsx index 4655e00..a34d746 100644 --- a/src/features/archive/ui/ColorSelect.tsx +++ b/src/features/archive/ui/ColorSelect.tsx @@ -1,23 +1,23 @@ import cn from 'classnames'; import styles from './ColorSelect.module.scss'; -import type { Color } from '../colors.type'; +import { ColorMap, type Color } from '../colors.type'; export const ColorSelect = ({ color, setColor, }: { - color: Color | 'default'; - setColor: (color: Color | 'default') => void; + color: Color; + setColor: (color: Color) => void; }) => { - const colors: (Color | 'default')[] = ['default', 'red', 'yellow', 'blue', 'green', 'purple']; + const colors: Color[] = ['DEFAULT', 'RED', 'YELLOW', 'BLUE', 'GREEN', 'PURPLE']; return (
{colors.map(c => (
{ diff --git a/src/features/archive/ui/CommentItem.module.scss b/src/features/archive/ui/CommentItem.module.scss index 027702a..f6301b4 100644 --- a/src/features/archive/ui/CommentItem.module.scss +++ b/src/features/archive/ui/CommentItem.module.scss @@ -26,8 +26,92 @@ .editBtns { display: flex; - gap: 0.5rem; + gap: 0.8rem; color: #afafaf; cursor: pointer; } } + +.editWrapper { + display: flex; + gap: 1rem; + width: 100%; + + .editArea { + width: 100%; + height: 5rem; + padding: 0.3rem; + font: inherit; + line-height: 1.2; + text-align: start; + word-break: break-word; + overflow-wrap: break-word; + white-space: normal; + vertical-align: top; + resize: none; + border: none; + border-radius: 4px; + outline: 1px solid #e9e9e9; + + @media (width <= 768px) { + height: 3rem; + } + + &::-webkit-scrollbar { + width: 5px; + height: 10px; + } + + &::-webkit-scrollbar-thumb { + background: #ededed; + border-radius: 12px; + } + + &:focus { + outline: 1px solid #e9e9e9; + } + } +} + +.editBtnsWrapper { + display: flex; + gap: 0.8rem; + color: #afafaf; + cursor: pointer; + transform: translateY(0.3rem); +} + +.edit, +.check, +.cancel, +.trash { + font-size: 0.8rem; +} + +.edit, +.check { + &:hover { + color: $green; + } +} + +.cancel, +.trash { + &:hover { + color: rgba($red, 0.8); + } +} + +.userInfo { + display: flex; + gap: 0.5rem; + align-items: center; + + img { + width: 1.5rem; + aspect-ratio: 1/1; + object-fit: contain; + background-color: #e9e9e9; + border-radius: 4px; + } +} diff --git a/src/features/archive/ui/CommentItem.tsx b/src/features/archive/ui/CommentItem.tsx index 4ba5d13..3c24beb 100644 --- a/src/features/archive/ui/CommentItem.tsx +++ b/src/features/archive/ui/CommentItem.tsx @@ -1,31 +1,75 @@ -import { faEdit, faTrash } from '@fortawesome/free-solid-svg-icons'; +import { faCircleCheck, faCircleXmark, faEdit, faTrash } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { useState } from 'react'; import styles from './CommentItem.module.scss'; import type { Comment } from '../archive.dto'; -import { useDeleteComment } from '../archive.hook'; +import { useDeleteComment, useUpdateComment } from '../archive.hook'; export const CommentItem = ({ comment, archiveId }: { comment: Comment; archiveId: number }) => { + const [editedContent, setEditedContent] = useState(comment.content); const { mutate: deleteComment } = useDeleteComment(archiveId); + const { mutate: editComment } = useUpdateComment(archiveId, comment.commentId, editedContent); + const [isEdit, setIsEdit] = useState(false); return (
-
-

{comment.username}

- {comment.isMine && ( -
- + {isEdit ? ( +
+