diff --git a/.github/workflows/frontend_cd.yml b/.github/workflows/frontend_cd.yml index 6a6dffe28..72df7983d 100644 --- a/.github/workflows/frontend_cd.yml +++ b/.github/workflows/frontend_cd.yml @@ -22,6 +22,11 @@ jobs: with: node-version: "20" + - name: pnpm 설치 + uses: pnpm/action-setup@v2 + with: + version: 8 + - name: 환경 파일 생성 run: | if [ "${{ github.ref_name }}" == "main" ]; then @@ -38,12 +43,11 @@ jobs: - name: 환경 파일 권한 설정 run: chmod 644 ${{ env.frontend-directory }}/.env.* - - name: npm install - run: npm install - working-directory: ${{ env.frontend-directory }} + - name: 의존성 설치 + run: pnpm install - - name: npm run build - run: npm run build + - name: 빌드 실행 + run: pnpm run build working-directory: ${{ env.frontend-directory }} - name: AWS credentials 설정 diff --git a/.github/workflows/frontend_ci.yml b/.github/workflows/frontend_ci.yml index d15ac86e8..98195c7da 100644 --- a/.github/workflows/frontend_ci.yml +++ b/.github/workflows/frontend_ci.yml @@ -27,7 +27,6 @@ jobs: - name: 의존성 설치 run: pnpm install - working-directory: ${{ env.frontend-directory }} - name: 타입 체크 실행 run: pnpm run tsc diff --git a/frontend/src/api/categories.ts b/frontend/src/api/categories.ts index 7c0d347f8..0fa1f9393 100644 --- a/frontend/src/api/categories.ts +++ b/frontend/src/api/categories.ts @@ -1,10 +1,6 @@ import { apiClient } from '@/api/config'; import { END_POINTS } from '@/routes'; -import type { - CategoryUploadRequest, - CategoryEditRequest, - CategoryDeleteRequest, -} from '@/types'; +import type { CategoryEditRequest, Category } from '@/types'; export const getCategoryList = async (memberId: number) => { const queryParams = new URLSearchParams({ @@ -17,17 +13,10 @@ export const getCategoryList = async (memberId: number) => { return await response.json(); }; -export const postCategory = async (newCategory: CategoryUploadRequest) => { - const response = await apiClient.post( - `${END_POINTS.CATEGORIES}`, - newCategory, - ); +export const postCategory = async (newCategory: Omit) => { + const response = await apiClient.post(`${END_POINTS.CATEGORIES}`, newCategory); return await response.json(); }; -export const editCategory = async ({ id, name }: CategoryEditRequest) => - await apiClient.put(`${END_POINTS.CATEGORIES}/${id}`, { name }); - -export const deleteCategory = async ({ id }: CategoryDeleteRequest) => - await apiClient.delete(`${END_POINTS.CATEGORIES}/${id}`); +export const editCategory = async (body: CategoryEditRequest) => await apiClient.put(`${END_POINTS.CATEGORIES}`, body); diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 264ac6f01..ce81c97fb 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -8,19 +8,8 @@ export { editTemplate, deleteTemplate, } from './templates'; -export { - postSignup, - postLogin, - postLogout, - getLoginState, - checkName, -} from './authentication'; -export { - getCategoryList, - postCategory, - editCategory, - deleteCategory, -} from './categories'; +export { postSignup, postLogin, postLogout, getLoginState, checkName } from './authentication'; +export { getCategoryList, postCategory, editCategory } from './categories'; export { getTagList } from './tags'; export { postLike, deleteLike } from './like'; export { getMemberName } from './members'; diff --git a/frontend/src/assets/images/drag.svg b/frontend/src/assets/images/drag.svg new file mode 100644 index 000000000..866bad48d --- /dev/null +++ b/frontend/src/assets/images/drag.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/images/index.ts b/frontend/src/assets/images/index.ts index 6dae1521d..b5b7ba918 100644 --- a/frontend/src/assets/images/index.ts +++ b/frontend/src/assets/images/index.ts @@ -20,6 +20,7 @@ export { default as LikeIcon } from './like'; export { default as PrivateIcon } from './private.svg'; export { default as PublicIcon } from './public.svg'; export { default as ShareIcon } from './share.svg'; +export { default as DragIcon } from './drag.svg'; // Logo export { default as CodeZapLogo } from './codezapLogo.svg'; diff --git a/frontend/src/hooks/category/useCategory.ts b/frontend/src/hooks/category/useCategory.ts index 7bfe85e03..c33e2934d 100644 --- a/frontend/src/hooks/category/useCategory.ts +++ b/frontend/src/hooks/category/useCategory.ts @@ -15,7 +15,7 @@ export const useCategory = ({ memberId, initCategory }: Props) => { const options = data?.categories || []; if (!initCategory) { - initCategory = { id: options[0]?.id, name: '카테고리 없음' }; + initCategory = { id: options[0]?.id, name: '카테고리 없음', ordinal: 0 }; } const { @@ -42,7 +42,7 @@ export const useCategory = ({ memberId, initCategory }: Props) => { return; } - const newCategory = { name: categoryName }; + const newCategory = { name: categoryName, ordinal: options.length }; await postCategory(newCategory); }; diff --git a/frontend/src/hooks/category/useCategoryNameValidation.ts b/frontend/src/hooks/category/useCategoryNameValidation.ts index c46e4e971..f8e7757e5 100644 --- a/frontend/src/hooks/category/useCategoryNameValidation.ts +++ b/frontend/src/hooks/category/useCategoryNameValidation.ts @@ -4,11 +4,7 @@ import type { Category } from '@/types'; const INVALID_NAMES = ['전체보기', '카테고리 없음', '']; -export const useCategoryNameValidation = ( - categories: Category[], - newCategories: { id: number; name: string }[], - editedCategories: Record, -) => { +export const useCategoryNameValidation = (categoryList: Category[], editedCategoryList: Category[]) => { const [invalidIds, setInvalidIds] = useState([]); useEffect(() => { @@ -23,7 +19,7 @@ export const useCategoryNameValidation = ( allNames.get(name)!.push(id); }; - categories.forEach(({ id, name }) => { + categoryList.forEach(({ id, name }) => { if (INVALID_NAMES.includes(name)) { invalidNames.add(id); } else { @@ -31,23 +27,13 @@ export const useCategoryNameValidation = ( } }); - newCategories.forEach(({ id, name }) => { - if (INVALID_NAMES.includes(name)) { - invalidNames.add(id); - } else { - addNameToMap(id, name); - } - }); - - Object.entries(editedCategories).forEach(([id, name]) => { - const originalName = categories.find( - (category) => category.id === Number(id), - )?.name; + editedCategoryList.forEach(({ id, name }) => { + const originalName = categoryList.find((category) => category.id === id)?.name; if (INVALID_NAMES.includes(name)) { - invalidNames.add(Number(id)); + invalidNames.add(id); } else if (name !== originalName) { - addNameToMap(Number(id), name); + addNameToMap(id, name); } }); @@ -58,7 +44,7 @@ export const useCategoryNameValidation = ( }); setInvalidIds(Array.from(invalidNames)); - }, [categories, newCategories, editedCategories]); + }, [categoryList, editedCategoryList]); return { invalidIds, diff --git a/frontend/src/mocks/fixtures/categoryList.json b/frontend/src/mocks/fixtures/categoryList.json index ed83c49aa..ab45e18d6 100644 --- a/frontend/src/mocks/fixtures/categoryList.json +++ b/frontend/src/mocks/fixtures/categoryList.json @@ -2,19 +2,23 @@ "categories": [ { "id": 1, - "name": "카테고리 없음" + "name": "카테고리 없음", + "ordinal": 0 }, { "id": 2, - "name": "Category1" + "name": "Category1", + "ordinal": 1 }, { "id": 3, - "name": "Category2" + "name": "Category2", + "ordinal": 2 }, { "id": 4, - "name": "Category3" + "name": "Category3", + "ordinal": 3 } ] } diff --git a/frontend/src/mocks/handlers/category.ts b/frontend/src/mocks/handlers/category.ts index a50e92765..dc27ac920 100644 --- a/frontend/src/mocks/handlers/category.ts +++ b/frontend/src/mocks/handlers/category.ts @@ -33,28 +33,12 @@ export const categoryHandlers = [ }); }), - http.put(`${API_URL}${END_POINTS.CATEGORIES}/:id`, async (req) => { - const { id } = req.params; + http.put(`${API_URL}${END_POINTS.CATEGORIES}`, async (req) => { const updatedCategory = await req.request.json(); - const categoryIndex = mockCategoryList.findIndex( - (cat) => cat.id.toString() === id, - ); - - if ( - categoryIndex !== -1 && - typeof updatedCategory === 'object' && - updatedCategory !== null - ) { - mockCategoryList[categoryIndex] = { - id: parseInt(id as string), - ...updatedCategory, - } as Category; + if (typeof updatedCategory === 'object' && updatedCategory !== null) { return mockResponse({ status: 200, - body: { - category: mockCategoryList[categoryIndex], - }, }); } @@ -65,26 +49,4 @@ export const categoryHandlers = [ }, }); }), - - http.delete(`${API_URL}${END_POINTS.CATEGORIES}/:id`, (req) => { - const { id } = req.params; - const categoryIndex = mockCategoryList.findIndex( - (cat) => cat.id.toString() === id, - ); - - if (categoryIndex !== -1) { - mockCategoryList.splice(categoryIndex, 1); - - return mockResponse({ - status: 204, - }); - } - - return mockResponse({ - status: 404, - body: { - message: 'Category not found', - }, - }); - }), ]; diff --git a/frontend/src/pages/LandingPage/LandingPage.tsx b/frontend/src/pages/LandingPage/LandingPage.tsx index 756bcd927..dcea0277c 100644 --- a/frontend/src/pages/LandingPage/LandingPage.tsx +++ b/frontend/src/pages/LandingPage/LandingPage.tsx @@ -24,6 +24,12 @@ const LandingPage = () => { const { isLogin } = useAuth(); + const EXPLAIN = [ + { title: 'ZAP하게 저장', description: '자주 쓰는 나의 코드를 간편하게 저장하세요' }, + { title: 'ZAP하게 관리', description: '직관적인 분류 시스템으로 체계적으로 관리하세요' }, + { title: 'ZAP하게 검색', description: '필요한 나의 코드를 빠르게 찾아 사용하세요' }, + ]; + return ( @@ -47,33 +53,15 @@ const LandingPage = () => { - - - - ZAP하게 저장 - - - 자주 쓰는 나의 코드를 간편하게 저장하세요 - - - - - - ZAP하게 관리 - - - 직관적인 분류 시스템으로 체계적으로 관리하세요 - - - - - - ZAP하게 검색 - - - 필요한 나의 코드를 빠르게 찾아 사용하세요 - - + {EXPLAIN.map((el, idx) => ( + + + + {el.title} + + {el.description} + + ))} diff --git a/frontend/src/pages/MemberTemplatePage/components/CategoryEditModal/CategoryEditModal.style.ts b/frontend/src/pages/MemberTemplatePage/components/CategoryEditModal/CategoryEditModal.style.ts index 6f8776219..e2ca84429 100644 --- a/frontend/src/pages/MemberTemplatePage/components/CategoryEditModal/CategoryEditModal.style.ts +++ b/frontend/src/pages/MemberTemplatePage/components/CategoryEditModal/CategoryEditModal.style.ts @@ -9,11 +9,9 @@ export const EditCategoryItemList = styled.div` width: 100%; `; -export const EditCategoryItem = styled.div<{ - hasError?: boolean; - isButton?: boolean; - disabled?: boolean; -}>` +export const EditCategoryItem = styled.div<{ hasError?: boolean; isButton?: boolean; disabled?: boolean }>` + cursor: move; + display: flex; gap: 1rem; align-items: center; diff --git a/frontend/src/pages/MemberTemplatePage/components/CategoryEditModal/CategoryEditModal.tsx b/frontend/src/pages/MemberTemplatePage/components/CategoryEditModal/CategoryEditModal.tsx index ccc6ea380..97c7b654b 100644 --- a/frontend/src/pages/MemberTemplatePage/components/CategoryEditModal/CategoryEditModal.tsx +++ b/frontend/src/pages/MemberTemplatePage/components/CategoryEditModal/CategoryEditModal.tsx @@ -1,165 +1,35 @@ -import { theme } from '@design/style/theme'; -import { css } from '@emotion/react'; -import { useState } from 'react'; - -import { PencilIcon, SpinArrowIcon, TrashcanIcon } from '@/assets/images'; -import { Text, Modal, Input, Flex, Button } from '@/components'; -import { useCategoryNameValidation } from '@/hooks/category'; -import { - useCategoryDeleteMutation, - useCategoryEditMutation, - useCategoryUploadMutation, -} from '@/queries/categories'; -import { validateCategoryName } from '@/service/validates'; -import { ICON_SIZE } from '@/style/styleConstants'; -import type { Category, ErrorBody } from '@/types'; +import { Text, Modal, Flex, Button } from '@/components'; +import type { Category } from '@/types'; +import CategoryItems from './CategoryItems'; +import { useCategoryEditModal } from '../../hooks'; import * as S from './CategoryEditModal.style'; interface CategoryEditModalProps { isOpen: boolean; toggleModal: () => void; - categories: Category[]; - handleCancelEdit: () => void; + categoryList: Category[]; onDeleteCategory: (deletedIds: number[]) => void; } -const CategoryEditModal = ({ - isOpen, - toggleModal, - categories, - handleCancelEdit, - onDeleteCategory, -}: CategoryEditModalProps) => { - const [editedCategories, setEditedCategories] = useState< - Record - >({}); - const [categoriesToDelete, setCategoriesToDelete] = useState([]); - const [newCategories, setNewCategories] = useState< - { id: number; name: string }[] - >([]); - const [editingCategoryId, setEditingCategoryId] = useState( - null, - ); - - const { mutateAsync: editCategory } = useCategoryEditMutation(); - const { mutateAsync: deleteCategory } = useCategoryDeleteMutation(categories); - const { mutateAsync: postCategory } = useCategoryUploadMutation(); - - const { invalidIds, isValid } = useCategoryNameValidation( - categories, - newCategories, - editedCategories, - ); - - const resetState = () => { - setEditedCategories({}); - setCategoriesToDelete([]); - setNewCategories([]); - setEditingCategoryId(null); - }; - - const isCategoryNew = (id: number) => - newCategories.some((category) => category.id === id); - - const handleNameInputChange = (id: number, name: string) => { - const errorMessage = validateCategoryName(name); - - if (errorMessage && name.length > 0) { - return; - } - - if (isCategoryNew(id)) { - setNewCategories((prev) => - prev.map((category) => - category.id === id ? { ...category, name } : category, - ), - ); - } else { - setEditedCategories((prev) => ({ ...prev, [id]: name })); - } - }; - - const handleDeleteClick = (id: number) => { - if (isCategoryNew(id)) { - setNewCategories((prev) => prev.filter((category) => category.id !== id)); - } else { - setCategoriesToDelete((prev) => [...prev, id]); - } - }; - - const handleRestoreClick = (id: number) => { - setCategoriesToDelete((prev) => - prev.filter((categoryId) => categoryId !== id), - ); - }; - - const handleEditClick = (id: number) => { - setEditingCategoryId(id); - }; - - const handleNameInputBlur = (id: number) => { - const trimmedName = isCategoryNew(id) - ? newCategories.find((category) => category.id === id)?.name.trim() - : editedCategories[id]?.trim(); - - if (trimmedName !== undefined) { - handleNameInputChange(id, trimmedName); - } - - setEditingCategoryId(null); - }; - - const handleAddCategory = () => { - const newCategoryId = - categories.length > 0 - ? categories[categories.length - 1].id + newCategories.length + 1 - : newCategories.length + 1; - - setNewCategories((prev) => [...prev, { id: newCategoryId, name: '' }]); - setEditingCategoryId(newCategoryId); - }; - - const handleSaveChanges = async () => { - if (!isValid) { - return; - } - - try { - if (categoriesToDelete.length > 0) { - await Promise.all( - categoriesToDelete.map((id) => deleteCategory({ id })), - ); - onDeleteCategory(categoriesToDelete); - } - - await Promise.all( - Object.entries(editedCategories).map(async ([id, name]) => { - const originalCategory = categories.find( - (category) => category.id === Number(id), - ); - - if (originalCategory && originalCategory.name !== name) { - await editCategory({ id: Number(id), name }); - } - }), - ); - - await Promise.all( - newCategories.map((category) => postCategory({ name: category.name })), - ); - - resetState(); - toggleModal(); - } catch (error) { - console.error((error as ErrorBody).detail); - } - }; - - const handleCancelEditWithReset = () => { - resetState(); - handleCancelEdit(); - }; +const CategoryEditModal = ({ isOpen, toggleModal, categoryList, onDeleteCategory }: CategoryEditModalProps) => { + const { + editedCategoryList, + deleteCategoryIds, + editingCategoryId, + invalidIds, + isValid, + isNewCategory, + handleNameInputChange, + handleOrdinalChange, + handleDeleteClick, + handleRestoreClick, + handleEditClick, + handleNameInputBlur, + handleAddCategory, + handleSaveChanges, + handleCancelEditWithReset, + } = useCategoryEditModal({ categoryList, toggleModal, onDeleteCategory }); return ( @@ -167,12 +37,12 @@ const CategoryEditModal = ({ ; - categoriesToDelete: number[]; - editingCategoryId: number | null; - invalidIds: number[]; - onEditClick: (id: number) => void; - onDeleteClick: (id: number) => void; - onRestoreClick: (id: number) => void; - onNameInputChange: (id: number, name: string) => void; - onNameInputBlur: (id: number) => void; -} - -const CategoryItems = ({ - categories, - newCategories, - editedCategories, - categoriesToDelete, - editingCategoryId, - invalidIds, - onEditClick, - onDeleteClick, - onRestoreClick, - onNameInputChange, - onNameInputBlur, -}: CategoryItemsProps) => ( - <> - {categories.map(({ id, name }) => ( - - {categoriesToDelete.includes(id) ? ( - // 기존 : 삭제 상태 - <> - - - {name} - - - onRestoreClick(id)} /> - - ) : ( - <> - - {editingCategoryId === id ? ( - // 기존 : 수정 상태 - - onNameInputChange(id, e.target.value)} - onBlur={() => onNameInputBlur(id)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - onNameInputBlur(id); - } - }} - autoFocus - css={css` - font-weight: bold; - &::placeholder { - font-weight: normal; - color: ${theme.color.light.secondary_400}; - } - `} - /> - - ) : ( - // 기존 : 기본 상태 - - {editedCategories[id] !== undefined - ? editedCategories[id] - : name} - - )} - - onEditClick(id)} - onDeleteClick={() => onDeleteClick(id)} - /> - - )} - - ))} - - {newCategories.map(({ id, name }) => ( - - - {editingCategoryId === id ? ( - // 생성 : 수정 상태 - - onNameInputChange(id, e.target.value)} - onBlur={() => onNameInputBlur(id)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - onNameInputBlur(id); - } - }} - autoFocus - css={css` - font-weight: bold; - &::placeholder { - font-weight: normal; - color: ${theme.color.light.secondary_400}; - } - `} - /> - - ) : ( - // 생성 : 기본 상태 - - {name} - - )} - - onEditClick(id)} - onDeleteClick={() => onDeleteClick(id)} - /> - - ))} - -); - -interface IconButtonsProps { - onRestoreClick?: () => void; - onEditClick?: () => void; - onDeleteClick?: () => void; - restore?: boolean; - edit?: boolean; - delete?: boolean; -} - -const IconButtons = ({ - onRestoreClick, - onEditClick, - onDeleteClick, - restore, - edit, - delete: del, -}: IconButtonsProps) => ( - - {restore && ( - - - - )} - {edit && ( - - - - )} - {del && ( - - - - )} - -); - export default CategoryEditModal; diff --git a/frontend/src/pages/MemberTemplatePage/components/CategoryEditModal/CategoryItems.tsx b/frontend/src/pages/MemberTemplatePage/components/CategoryEditModal/CategoryItems.tsx new file mode 100644 index 000000000..b5e6a3c1d --- /dev/null +++ b/frontend/src/pages/MemberTemplatePage/components/CategoryEditModal/CategoryItems.tsx @@ -0,0 +1,134 @@ +import { useRef } from 'react'; + +import { theme } from '@/style/theme'; +import { Category } from '@/types'; + +import ExistingCategoryItem from './ExistingCategoryItem'; +import NewCategoryItem from './NewCategoryItem'; +import * as S from './CategoryEditModal.style'; + +interface CategoryItemsProps { + editedCategoryList: Category[]; + deleteCategoryIds: number[]; + editingCategoryId: number | null; + invalidIds: number[]; + isNewCategory: (id: number) => boolean; + handleOrdinalChange: (categoryList: Category[]) => void; + onEditClick: (id: number) => void; + onDeleteClick: (id: number) => void; + onRestoreClick: (id: number) => void; + onNameInputChange: (id: number, name: string) => void; + onNameInputBlur: (id: number) => void; +} + +const CategoryItems = ({ + editedCategoryList, + deleteCategoryIds, + editingCategoryId, + invalidIds, + isNewCategory, + handleOrdinalChange, + onEditClick, + onDeleteClick, + onRestoreClick, + onNameInputChange, + onNameInputBlur, +}: CategoryItemsProps) => { + const orderedCategoryList = [...editedCategoryList].sort((a, b) => a.ordinal - b.ordinal); + + const dragItem = useRef(null); + const dragOverItem = useRef(null); + + const handleDragStart = (e: React.DragEvent, position: number) => { + dragItem.current = position; + e.currentTarget.style.opacity = '0.5'; + }; + + const handleDragEnter = (e: React.DragEvent, position: number) => { + dragOverItem.current = position; + e.currentTarget.style.backgroundColor = theme.color.dark.white; + }; + + const handleDragEnd = (e: React.DragEvent) => { + if (dragItem.current === null || dragOverItem.current === null) { + return; + } + + e.currentTarget.style.opacity = '1'; + e.currentTarget.style.backgroundColor = ''; + + const reorderedCategoryList = getReorderedCategoryList(orderedCategoryList, dragItem.current, dragOverItem.current); + + handleOrdinalChange(reorderedCategoryList); + + dragItem.current = null; + dragOverItem.current = null; + }; + + const getReorderedCategoryList = (categoryList: Category[], startIndex: number, endIndex: number) => { + const copyListItems = [...categoryList]; + const dragItem = copyListItems[startIndex]; + + copyListItems.splice(startIndex, 1); + copyListItems.splice(endIndex, 0, dragItem); + + return copyListItems; + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.currentTarget.style.backgroundColor = ''; + }; + + return ( + <> + {orderedCategoryList.map(({ id, name }, index) => ( + handleDragStart(e, index)} + onDragEnter={(e) => handleDragEnter(e, index)} + onDragEnd={handleDragEnd} + onDragLeave={handleDragLeave} + onDragOver={(e) => e.preventDefault()} + > + {isNewCategory(id) ? ( + onNameInputChange(id, e.target.value)} + onBlur={() => onNameInputBlur(id)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + onNameInputBlur(id); + } + }} + onEditClick={() => onEditClick(id)} + onDeleteClick={() => onDeleteClick(id)} + /> + ) : ( + category.id === id)?.name ?? name} + isEditing={editingCategoryId === id} + isDeleted={deleteCategoryIds.includes(id)} + onChange={(e) => onNameInputChange(id, e.target.value)} + onBlur={() => onNameInputBlur(id)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + onNameInputBlur(id); + } + }} + onEditClick={() => onEditClick(id)} + onDeleteClick={() => onDeleteClick(id)} + onRestoreClick={() => onRestoreClick(id)} + /> + )} + + ))} + + ); +}; + +export default CategoryItems; diff --git a/frontend/src/pages/MemberTemplatePage/components/CategoryEditModal/CategoryName.tsx b/frontend/src/pages/MemberTemplatePage/components/CategoryEditModal/CategoryName.tsx new file mode 100644 index 000000000..383d2eee1 --- /dev/null +++ b/frontend/src/pages/MemberTemplatePage/components/CategoryEditModal/CategoryName.tsx @@ -0,0 +1,14 @@ +import { PropsWithChildren } from 'react'; + +import { DragIcon } from '@/assets/images'; +import { Flex } from '@/components'; +import { theme } from '@/style/theme'; + +const CategoryName = ({ children }: PropsWithChildren) => ( + + + {children} + +); + +export default CategoryName; diff --git a/frontend/src/pages/MemberTemplatePage/components/CategoryEditModal/CategoryNameInput.tsx b/frontend/src/pages/MemberTemplatePage/components/CategoryEditModal/CategoryNameInput.tsx new file mode 100644 index 000000000..26ad8122c --- /dev/null +++ b/frontend/src/pages/MemberTemplatePage/components/CategoryEditModal/CategoryNameInput.tsx @@ -0,0 +1,34 @@ +import { css } from '@emotion/react'; + +import { Input } from '@/components'; +import { theme } from '@/style/theme'; + +interface CategoryNameInputProps { + value: string; + onChange: (e: React.ChangeEvent) => void; + onBlur: () => void; + onKeyDown: (e: React.KeyboardEvent) => void; +} + +const CategoryNameInput = ({ value, onChange, onBlur, onKeyDown }: CategoryNameInputProps) => ( + + + +); + +export default CategoryNameInput; diff --git a/frontend/src/pages/MemberTemplatePage/components/CategoryEditModal/ExistingCategoryItem.tsx b/frontend/src/pages/MemberTemplatePage/components/CategoryEditModal/ExistingCategoryItem.tsx new file mode 100644 index 000000000..befafda1c --- /dev/null +++ b/frontend/src/pages/MemberTemplatePage/components/CategoryEditModal/ExistingCategoryItem.tsx @@ -0,0 +1,60 @@ +import { Text } from '@/components'; +import { theme } from '@/style/theme'; + +import CategoryName from './CategoryName'; +import CategoryNameInput from './CategoryNameInput'; +import IconButtons from './IconButtons'; + +interface ExistingCategoryItemProps { + id: number; + name: string; + isEditing: boolean; + isDeleted: boolean; + onChange: (e: React.ChangeEvent) => void; + onBlur: () => void; + onKeyDown: (e: React.KeyboardEvent) => void; + onEditClick: (id: number) => void; + onDeleteClick: (id: number) => void; + onRestoreClick: (id: number) => void; +} + +const ExistingCategoryItem = ({ + id, + name, + isEditing, + isDeleted, + onChange, + onBlur, + onKeyDown, + onEditClick, + onDeleteClick, + onRestoreClick, +}: ExistingCategoryItemProps) => ( + <> + {isDeleted ? ( + <> + + + {name} + + + onRestoreClick(id)} /> + + ) : ( + <> + + {isEditing ? ( + + ) : ( + + {name} + + )} + + onEditClick(id)} onDeleteClick={() => onDeleteClick(id)} /> + + )} + +); + +export default ExistingCategoryItem; diff --git a/frontend/src/pages/MemberTemplatePage/components/CategoryEditModal/IconButtons.tsx b/frontend/src/pages/MemberTemplatePage/components/CategoryEditModal/IconButtons.tsx new file mode 100644 index 000000000..d6e4f1de9 --- /dev/null +++ b/frontend/src/pages/MemberTemplatePage/components/CategoryEditModal/IconButtons.tsx @@ -0,0 +1,35 @@ +import { PencilIcon, SpinArrowIcon, TrashcanIcon } from '@/assets/images'; +import { ICON_SIZE } from '@/style/styleConstants'; + +import * as S from './CategoryEditModal.style'; + +interface IconButtonsProps { + onRestoreClick?: () => void; + onEditClick?: () => void; + onDeleteClick?: () => void; + restore?: boolean; + edit?: boolean; + delete?: boolean; +} + +const IconButtons = ({ onRestoreClick, onEditClick, onDeleteClick, restore, edit, delete: del }: IconButtonsProps) => ( + + {restore && ( + + + + )} + {edit && ( + + + + )} + {del && ( + + + + )} + +); + +export default IconButtons; diff --git a/frontend/src/pages/MemberTemplatePage/components/CategoryEditModal/NewCategoryItem.tsx b/frontend/src/pages/MemberTemplatePage/components/CategoryEditModal/NewCategoryItem.tsx new file mode 100644 index 000000000..cafbd4437 --- /dev/null +++ b/frontend/src/pages/MemberTemplatePage/components/CategoryEditModal/NewCategoryItem.tsx @@ -0,0 +1,43 @@ +import { Text } from '@/components'; +import { theme } from '@/style/theme'; + +import CategoryName from './CategoryName'; +import CategoryNameInput from './CategoryNameInput'; +import IconButtons from './IconButtons'; + +interface NewCategoryItemProps { + id: number; + name: string; + isEditing: boolean; + onChange: (e: React.ChangeEvent) => void; + onBlur: () => void; + onKeyDown: (e: React.KeyboardEvent) => void; + onEditClick: (id: number) => void; + onDeleteClick: (id: number) => void; +} + +const NewCategoryItem = ({ + id, + name, + isEditing, + onChange, + onBlur, + onKeyDown, + onEditClick, + onDeleteClick, +}: NewCategoryItemProps) => ( + <> + + {isEditing ? ( + + ) : ( + + {name} + + )} + + onEditClick(id)} onDeleteClick={() => onDeleteClick(id)} /> + +); + +export default NewCategoryItem; diff --git a/frontend/src/pages/MemberTemplatePage/components/CategoryFilterMenu/CategoryFilterMenu.tsx b/frontend/src/pages/MemberTemplatePage/components/CategoryFilterMenu/CategoryFilterMenu.tsx index 3c29d7543..b2977c662 100644 --- a/frontend/src/pages/MemberTemplatePage/components/CategoryFilterMenu/CategoryFilterMenu.tsx +++ b/frontend/src/pages/MemberTemplatePage/components/CategoryFilterMenu/CategoryFilterMenu.tsx @@ -46,9 +46,9 @@ const CategoryFilterMenu = ({ } }; - const [defaultCategory, ...userCategories] = categoryList.length + const [defaultCategory, ...userCategoryList] = categoryList.length ? categoryList - : [{ id: 0, name: '' }]; + : [{ id: 0, name: '', ordinal: categoryList.length + 1 }]; const indexById: Record = useMemo(() => { const map: Record = { @@ -56,12 +56,12 @@ const CategoryFilterMenu = ({ [defaultCategory.id]: categoryList.length, }; - userCategories.forEach(({ id }, index) => { + userCategoryList.forEach(({ id }, index) => { map[id] = index + 1; }); return map; - }, [categoryList.length, defaultCategory.id, userCategories]); + }, [categoryList.length, defaultCategory.id, userCategoryList]); return ( <> @@ -98,7 +98,7 @@ const CategoryFilterMenu = ({ /> - {userCategories.map(({ id, name }) => ( + {userCategoryList.map(({ id, name }) => ( diff --git a/frontend/src/pages/MemberTemplatePage/components/CategoryListSection/CategoryListSection.tsx b/frontend/src/pages/MemberTemplatePage/components/CategoryListSection/CategoryListSection.tsx index 03e4d742b..c2e93cda0 100644 --- a/frontend/src/pages/MemberTemplatePage/components/CategoryListSection/CategoryListSection.tsx +++ b/frontend/src/pages/MemberTemplatePage/components/CategoryListSection/CategoryListSection.tsx @@ -16,7 +16,7 @@ const CategoryListSection = ({ onSelectCategory, memberId }: Props) => { a.ordinal - b.ordinal)} onSelectCategory={onSelectCategory} /> diff --git a/frontend/src/pages/MemberTemplatePage/hooks/index.ts b/frontend/src/pages/MemberTemplatePage/hooks/index.ts index 4825988ac..458fd8cd9 100644 --- a/frontend/src/pages/MemberTemplatePage/hooks/index.ts +++ b/frontend/src/pages/MemberTemplatePage/hooks/index.ts @@ -1,2 +1,3 @@ export { useFilteredTemplateList } from './useFilteredTemplateList'; export { useSelectAndDeleteTemplateList } from './useSelectAndDeleteTemplateList'; +export { useCategoryEditModal } from './useCategoryEditModal'; diff --git a/frontend/src/pages/MemberTemplatePage/hooks/useCategoryEditModal.ts b/frontend/src/pages/MemberTemplatePage/hooks/useCategoryEditModal.ts new file mode 100644 index 000000000..28e077d0e --- /dev/null +++ b/frontend/src/pages/MemberTemplatePage/hooks/useCategoryEditModal.ts @@ -0,0 +1,170 @@ +import { useEffect, useState } from 'react'; + +import { useCategoryNameValidation } from '@/hooks/category'; +import { useCategoryEditMutation } from '@/queries/categories'; +import { validateCategoryName } from '@/service/validates'; +import { Category } from '@/types'; + +interface Props { + categoryList: Category[]; + toggleModal: () => void; + onDeleteCategory: (deletedIds: number[]) => void; +} + +export const useCategoryEditModal = ({ categoryList, toggleModal, onDeleteCategory }: Props) => { + const [editedCategoryList, setEditedCategoryList] = useState([...categoryList]); + const [deleteCategoryIds, setDeleteCategoryIds] = useState([]); + const [editingCategoryId, setEditingCategoryId] = useState(null); + + const { mutateAsync: editCategory } = useCategoryEditMutation(); + + const { invalidIds, isValid } = useCategoryNameValidation(categoryList, editedCategoryList); + + useEffect(() => { + if (!isEqualCategoryList(categoryList, editedCategoryList)) { + setEditedCategoryList([...categoryList]); + } + }, [categoryList]); + + const isEqualCategoryList = (arr1: Category[], arr2: Category[]): boolean => { + if (arr1.length !== arr2.length) { + return false; + } + + return arr1.every((category, index) => { + const category2 = arr2[index]; + + return category.id === category2.id && category.name === category2.name && category.ordinal === category2.ordinal; + }); + }; + + const isNewCategory = (id: number) => categoryList.every((category) => category.id !== id); + + const resetState = () => { + setEditedCategoryList([...categoryList]); + setDeleteCategoryIds([]); + setEditingCategoryId(null); + }; + + const handleNameInputChange = (id: number, name: string) => { + const errorMessage = validateCategoryName(name); + + if (errorMessage && name.length > 0) { + return; + } + + setEditedCategoryList((prev) => prev.map((category) => (category.id === id ? { ...category, name } : category))); + }; + + const handleOrdinalChange = (categoryList: Category[]) => { + const updatedCategoryList = categoryList.map((category, index) => ({ + ...category, + ordinal: index + 1, + })); + + setEditedCategoryList(updatedCategoryList); + }; + + const handleDeleteClick = (id: number) => { + if (isNewCategory(id)) { + setEditedCategoryList((prev) => prev.filter((category) => category.id !== id)); + + const updatedCategoryList = [...editedCategoryList.filter((category) => category.id !== id)].sort( + (a, b) => a.ordinal - b.ordinal, + ); + + handleOrdinalChange(updatedCategoryList); + + return; + } + + setDeleteCategoryIds((prev) => [...prev, id]); + + const updatedCategoryList = [...editedCategoryList].sort((a, b) => a.ordinal - b.ordinal); + + handleOrdinalChange(updatedCategoryList); + }; + + const handleRestoreClick = (id: number) => { + setDeleteCategoryIds((prev) => prev.filter((categoryId) => categoryId !== id)); + }; + + const handleEditClick = (id: number) => { + setEditingCategoryId(id); + }; + + const handleNameInputBlur = (id: number) => { + const trimmedName = editedCategoryList.find((category) => category.id === id)?.name.trim(); + + if (trimmedName !== undefined) { + handleNameInputChange(id, trimmedName); + } + + setEditingCategoryId(null); + }; + + const handleAddCategory = () => { + const id = Date.now(); + + const ordinal = editedCategoryList.length + 1; + + setEditedCategoryList((prev) => [...prev, { id, name: '', ordinal }]); + setEditingCategoryId(id); + }; + + const handleSaveChanges = async () => { + if (!isValid) { + return; + } + + const body = getCategoryEditRequestBody(); + + await editCategory(body); + + if (deleteCategoryIds.length > 0) { + onDeleteCategory(deleteCategoryIds); + } + + resetState(); + toggleModal(); + }; + + const getCategoryEditRequestBody = () => { + const filteredCategoryList = editedCategoryList + .filter(({ id }) => !deleteCategoryIds.includes(id)) + .map((category, idx) => ({ ...category, ordinal: idx + 1 })); + + const body = { + createCategories: filteredCategoryList + .filter(({ id }) => isNewCategory(id)) + .map(({ name, ordinal }) => ({ name, ordinal })), + updateCategories: filteredCategoryList.filter(({ id }) => !isNewCategory(id)), + deleteCategoryIds, + }; + + return body; + }; + + const handleCancelEditWithReset = () => { + resetState(); + toggleModal(); + }; + + return { + editedCategoryList, + deleteCategoryIds, + editingCategoryId, + invalidIds, + isValid, + isNewCategory, + handleNameInputChange, + handleOrdinalChange, + handleDeleteClick, + handleRestoreClick, + handleEditClick, + handleNameInputBlur, + handleAddCategory, + handleSaveChanges, + handleCancelEditWithReset, + }; +}; diff --git a/frontend/src/queries/categories/index.ts b/frontend/src/queries/categories/index.ts index e3ff6d547..e8cde1b47 100644 --- a/frontend/src/queries/categories/index.ts +++ b/frontend/src/queries/categories/index.ts @@ -1,4 +1,3 @@ export { useCategoryListQuery } from './useCategoryListQuery'; export { useCategoryUploadMutation } from './useCategoryUploadMutation'; export { useCategoryEditMutation } from './useCategoryEditMutation'; -export { useCategoryDeleteMutation } from './useCategoryDeleteMutation'; diff --git a/frontend/src/queries/categories/useCategoryDeleteMutation.ts b/frontend/src/queries/categories/useCategoryDeleteMutation.ts deleted file mode 100644 index 8af93dd34..000000000 --- a/frontend/src/queries/categories/useCategoryDeleteMutation.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; - -import { QUERY_KEY, deleteCategory } from '@/api'; -import { ApiError } from '@/api/Error'; -import { ToastContext } from '@/contexts'; -import { useCustomContext } from '@/hooks'; -import { Category } from '@/types'; - -export const useCategoryDeleteMutation = (categories: Category[]) => { - const queryClient = useQueryClient(); - const { failAlert } = useCustomContext(ToastContext); - - return useMutation({ - mutationFn: deleteCategory, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEY.CATEGORY_LIST] }); - }, - onError: (error, targetCategory) => { - if (error instanceof ApiError) { - const categoryId = targetCategory.id; - const categoryName = - categories.find((category) => category.id === categoryId)?.name || - '카테고리를 찾을 수 없음'; - - if (error.statusCode === 400) { - failAlert( - `템플릿이 존재하는 카테고리(${categoryName})는 삭제할 수 없습니다.`, - ); - } else { - failAlert(`카테고리 삭제 중 오류가 발생했습니다: ${categoryName}`); - } - } - }, - }); -}; diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 89ff3ff77..f2c0b4920 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -46,17 +46,10 @@ export interface CategoryListResponse { categories: Category[]; } -export interface CategoryUploadRequest { - name: string; -} - export interface CategoryEditRequest { - id: number; - name: string; -} - -export interface CategoryDeleteRequest { - id: number; + createCategories: Omit[]; + updateCategories: Category[]; + deleteCategoryIds: number[]; } export interface TagListResponse { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 5d0719344..e5ec4d5de 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -16,9 +16,7 @@ export type { TemplateListRequest, LikePostRequest, LikeDeleteRequest, - CategoryUploadRequest, CategoryEditRequest, - CategoryDeleteRequest, CategoryListResponse, TagListResponse, GetMemberNameResponse, diff --git a/frontend/src/types/template.ts b/frontend/src/types/template.ts index 8fa14bdb6..f4cadbc31 100644 --- a/frontend/src/types/template.ts +++ b/frontend/src/types/template.ts @@ -20,6 +20,7 @@ export interface Tag { export interface Category { id: number; name: string; + ordinal: number; } export interface Template {