From 1b2a3b37d54ac746183fe5d7fcccd173795caec9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=97=A4=EC=9D=B8?= <157036488+Hain-tain@users.noreply.github.com> Date: Fri, 18 Oct 2024 15:03:14 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC,=20?= =?UTF-8?q?=ED=83=9C=EA=B7=B8,=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EB=A1=9C=EB=94=A9=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20(#813)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(components): ScrollTopButton 컴포넌트 분리 * refactor(MyTemplatePage): 컴포넌트 분리 및 태그목록, 카테고리 목록 서스펜스 적용 * refactor(components): TemplateDeleteSelection, TemplateListSectionLoading 컴포넌트 분리 * refactor(src): useKeyword, useShowTemplateList, useSelectAndDeleteTemplateList 훅 분리 및 MyTemplatePage 적용 * refactor(templates): 사용하지 않는 useTemplateCategoryTagQueries 훅 삭제 * refactor(pages): useShowTemplateList => useFilteredTemplateList로 이름 변경 * refactor(src): useKeyword => useSearchKeyword로 이름 변경 * refactor(ConfirmDeleteModal): ConfirmDeleteModalProps =>Props 로 변경 * refactor(CategoryListSection): Flex 대신 스타일드 컴포넌트로 스타일 지정 * refactor(ConfirmDeleteModal): Modal 합성 컴포넌트의 하위 컴포넌트 사용하도록 변경 * refactor(queries): useSuspenseQuery를 사용하는 훅들 수동으로 error 전파 --- .../ScrollTopButton/ScrollTopButton.style.ts | 21 ++ .../ScrollTopButton/ScrollTopButton.tsx | 16 ++ frontend/src/components/index.ts | 1 + frontend/src/hooks/template/index.ts | 1 + .../src/hooks/template/useSearchKeyword.ts | 8 + .../MyTemplatesPage/MyTemplatePage.style.ts | 36 +-- .../pages/MyTemplatesPage/MyTemplatePage.tsx | 266 +++++------------- .../CategoryListSection.style.ts | 8 + .../CategoryListSection.tsx | 21 ++ .../CategoryListSectionSkeleton.tsx | 52 ++++ .../ConfirmDeleteModal/ConfirmDeleteModal.tsx | 26 ++ .../NewTemplateButton.style.ts | 23 ++ .../NewTemplateButton/NewTemplateButton.tsx | 22 ++ .../TagListSection/TagListSection.tsx | 23 ++ .../TagListSection/TagListSectionSkeleton.tsx | 28 ++ .../TemplateDeleteSelection.tsx | 60 ++++ .../TemplateListSection.tsx | 35 +++ .../TemplateListSectionLoading.style.ts | 23 ++ .../TemplateListSectionLoading.tsx | 13 + .../components/TopBanner/TopBanner.style.ts | 18 ++ .../components/TopBanner/TopBanner.tsx | 21 ++ .../pages/MyTemplatesPage/components/index.ts | 10 + .../src/pages/MyTemplatesPage/hooks/.gitkeep | 0 .../src/pages/MyTemplatesPage/hooks/index.ts | 2 + .../hooks/useFilteredTemplateList.ts | 69 +++++ .../hooks/useSelectAndDeleteTemplateList.ts | 44 +++ .../categories/useCategoryListQuery.ts | 10 +- frontend/src/queries/tags/useTagListQuery.ts | 11 +- frontend/src/queries/templates/index.ts | 1 - .../useTemplateCategoryTagQueries.ts | 44 --- frontend/src/routes/router.tsx | 6 +- 31 files changed, 637 insertions(+), 282 deletions(-) create mode 100644 frontend/src/components/ScrollTopButton/ScrollTopButton.style.ts create mode 100644 frontend/src/components/ScrollTopButton/ScrollTopButton.tsx create mode 100644 frontend/src/hooks/template/useSearchKeyword.ts create mode 100644 frontend/src/pages/MyTemplatesPage/components/CategoryListSection/CategoryListSection.style.ts create mode 100644 frontend/src/pages/MyTemplatesPage/components/CategoryListSection/CategoryListSection.tsx create mode 100644 frontend/src/pages/MyTemplatesPage/components/CategoryListSection/CategoryListSectionSkeleton.tsx create mode 100644 frontend/src/pages/MyTemplatesPage/components/ConfirmDeleteModal/ConfirmDeleteModal.tsx create mode 100644 frontend/src/pages/MyTemplatesPage/components/NewTemplateButton/NewTemplateButton.style.ts create mode 100644 frontend/src/pages/MyTemplatesPage/components/NewTemplateButton/NewTemplateButton.tsx create mode 100644 frontend/src/pages/MyTemplatesPage/components/TagListSection/TagListSection.tsx create mode 100644 frontend/src/pages/MyTemplatesPage/components/TagListSection/TagListSectionSkeleton.tsx create mode 100644 frontend/src/pages/MyTemplatesPage/components/TemplateDeleteSelection/TemplateDeleteSelection.tsx create mode 100644 frontend/src/pages/MyTemplatesPage/components/TemplateListSection/TemplateListSection.tsx create mode 100644 frontend/src/pages/MyTemplatesPage/components/TemplateListSection/TemplateListSectionLoading.style.ts create mode 100644 frontend/src/pages/MyTemplatesPage/components/TemplateListSection/TemplateListSectionLoading.tsx create mode 100644 frontend/src/pages/MyTemplatesPage/components/TopBanner/TopBanner.style.ts create mode 100644 frontend/src/pages/MyTemplatesPage/components/TopBanner/TopBanner.tsx delete mode 100644 frontend/src/pages/MyTemplatesPage/hooks/.gitkeep create mode 100644 frontend/src/pages/MyTemplatesPage/hooks/index.ts create mode 100644 frontend/src/pages/MyTemplatesPage/hooks/useFilteredTemplateList.ts create mode 100644 frontend/src/pages/MyTemplatesPage/hooks/useSelectAndDeleteTemplateList.ts delete mode 100644 frontend/src/queries/templates/useTemplateCategoryTagQueries.ts diff --git a/frontend/src/components/ScrollTopButton/ScrollTopButton.style.ts b/frontend/src/components/ScrollTopButton/ScrollTopButton.style.ts new file mode 100644 index 000000000..7061e015d --- /dev/null +++ b/frontend/src/components/ScrollTopButton/ScrollTopButton.style.ts @@ -0,0 +1,21 @@ +import styled from '@emotion/styled'; + +import { theme } from '@/style/theme'; + +export const ScrollTopButton = styled.button` + cursor: pointer; + + position: fixed; + right: 2rem; + bottom: 2rem; + + display: flex; + align-items: center; + justify-content: center; + + padding: 0.75rem; + + background-color: ${theme.color.light.primary_500}; + border: none; + border-radius: 100%; +`; diff --git a/frontend/src/components/ScrollTopButton/ScrollTopButton.tsx b/frontend/src/components/ScrollTopButton/ScrollTopButton.tsx new file mode 100644 index 000000000..7f51523ef --- /dev/null +++ b/frontend/src/components/ScrollTopButton/ScrollTopButton.tsx @@ -0,0 +1,16 @@ +import { ArrowUpIcon } from '@/assets/images'; +import { scroll } from '@/utils'; + +import * as S from './ScrollTopButton.style'; + +const ScrollTopButton = () => ( + { + scroll.top('smooth'); + }} + > + + +); + +export default ScrollTopButton; diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 7d3500417..27b9ab5dc 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -27,6 +27,7 @@ export { default as NewCategoryInput } from './NewCategoryInput/NewCategoryInput export { default as NoSearchResults } from './NoSearchResults/NoSearchResults'; export { default as Textarea } from './Textarea/Textarea'; export { default as ContactUs } from './ContactUs/ContactUs'; +export { default as ScrollTopButton } from './ScrollTopButton/ScrollTopButton'; // Skeleton UI export { default as LoadingBall } from './LoadingBall/LoadingBall'; diff --git a/frontend/src/hooks/template/index.ts b/frontend/src/hooks/template/index.ts index d071d71ba..56cdbcf6e 100644 --- a/frontend/src/hooks/template/index.ts +++ b/frontend/src/hooks/template/index.ts @@ -1,2 +1,3 @@ export { useSourceCode } from './useSourceCode'; export { useTag } from './useTag'; +export { useSearchKeyword } from './useSearchKeyword'; diff --git a/frontend/src/hooks/template/useSearchKeyword.ts b/frontend/src/hooks/template/useSearchKeyword.ts new file mode 100644 index 000000000..63c2c0394 --- /dev/null +++ b/frontend/src/hooks/template/useSearchKeyword.ts @@ -0,0 +1,8 @@ +import { useInput, useDebounce } from '..'; + +export const useSearchKeyword = () => { + const [keyword, handleKeywordChange] = useInput(''); + const debouncedKeyword = useDebounce(keyword, 300); + + return { keyword, debouncedKeyword, handleKeywordChange }; +}; diff --git a/frontend/src/pages/MyTemplatesPage/MyTemplatePage.style.ts b/frontend/src/pages/MyTemplatesPage/MyTemplatePage.style.ts index 7c5f3fec8..f55c62df4 100644 --- a/frontend/src/pages/MyTemplatesPage/MyTemplatePage.style.ts +++ b/frontend/src/pages/MyTemplatesPage/MyTemplatePage.style.ts @@ -21,23 +21,6 @@ export const MainContainer = styled.main` } `; -export const TopBannerContainer = styled.div` - display: flex; - align-items: center; - - width: 100%; - height: 10.25rem; - - white-space: nowrap; -`; - -export const TopBannerTextWrapper = styled.div` - display: flex; - gap: 0.5rem; - align-items: center; - margin-left: calc(12.5rem + clamp(1rem, calc(0.0888 * 100vw - 3.2618rem), 4.375rem)); -`; - export const SearchInput = styled(Input)` box-shadow: inset 1px 2px 8px #00000030; `; @@ -62,20 +45,7 @@ export const NewTemplateButton = styled.button` } `; -export const ScrollTopButton = styled.button` - cursor: pointer; - - position: fixed; - right: 2rem; - bottom: 2rem; - - display: flex; - align-items: center; - justify-content: center; - - padding: 0.75rem; - - background-color: ${theme.color.light.primary_500}; - border: none; - border-radius: 100%; +export const TemplateListSectionWrapper = styled.div` + position: relative; + width: 100%; `; diff --git a/frontend/src/pages/MyTemplatesPage/MyTemplatePage.tsx b/frontend/src/pages/MyTemplatesPage/MyTemplatePage.tsx index f067383fb..a74fd014b 100644 --- a/frontend/src/pages/MyTemplatesPage/MyTemplatePage.tsx +++ b/frontend/src/pages/MyTemplatesPage/MyTemplatePage.tsx @@ -1,141 +1,75 @@ -import { useState, useCallback } from 'react'; +import { Suspense } from 'react'; -import { DEFAULT_SORTING_OPTION, SORTING_OPTIONS } from '@/api'; -import { ArrowUpIcon, PlusIcon, SearchIcon } from '@/assets/images'; -import { Flex, Heading, Input, PagingButtons, Dropdown, Button, Modal, Text, NoSearchResults } from '@/components'; -import { useWindowWidth, useDebounce, useToggle, useDropdown, useInput, useCustomNavigate } from '@/hooks'; +import { SORTING_OPTIONS } from '@/api'; +import { SearchIcon } from '@/assets/images'; +import { Flex, Input, PagingButtons, Dropdown, ScrollTopButton } from '@/components'; import { useAuth } from '@/hooks/authentication'; -import { useTemplateDeleteMutation, useTemplateCategoryTagQueries } from '@/queries/templates'; -import { END_POINTS } from '@/routes'; -import { theme } from '@/style/theme'; -import { scroll } from '@/utils'; -import { CategoryFilterMenu, TemplateGrid, TagFilterMenu } from './components'; +import { + TopBanner, + CategoryListSection, + CategoryListSectionSkeleton, + TagListSection, + TagListSectionSkeleton, + TemplateListSection, + TemplateDeleteSelection, + TemplateListSectionLoading, +} from './components'; +import { useSelectAndDeleteTemplateList, useFilteredTemplateList } from './hooks'; import * as S from './MyTemplatePage.style'; -const getGridCols = (windowWidth: number) => (windowWidth <= 1024 ? 1 : 2); - const MyTemplatePage = () => { - const windowWidth = useWindowWidth(); const { memberInfo: { name }, } = useAuth(); - const [isEditMode, toggleIsEditMode] = useToggle(); - const [selectedList, setSelectedList] = useState([]); - const [isDeleteModalOpen, toggleDeleteModal] = useToggle(); - - const [keyword, handleKeywordChange] = useInput(''); - const debouncedKeyword = useDebounce(keyword, 300); - const [selectedCategoryId, setSelectedCategoryId] = useState(undefined); - const [selectedTagIds, setSelectedTagIds] = useState([]); - const { currentValue: sortingOption, ...dropdownProps } = useDropdown(DEFAULT_SORTING_OPTION); - - const [page, setPage] = useState(1); - - const [{ data: templateData }, { data: categoryData }, { data: tagData }] = useTemplateCategoryTagQueries({ - keyword: debouncedKeyword, - categoryId: selectedCategoryId, - tagIds: selectedTagIds, - sort: sortingOption.key, + const { + templateList, + isTemplateListFetching, + isTemplateListLoading, + totalPages, + dropdownProps, + keyword, page, - }); - - const templateList = templateData?.templates || []; - const categoryList = categoryData?.categories || []; - const tagList = tagData?.tags || []; - const totalPages = templateData?.totalPages || 0; - - const { mutateAsync: deleteTemplates } = useTemplateDeleteMutation(selectedList); - - const handleCategoryMenuClick = useCallback((selectedCategoryId: number) => { - setSelectedCategoryId(selectedCategoryId); - setPage(1); - }, []); - - const handleTagMenuClick = useCallback((selectedTagIds: number[]) => { - setSelectedTagIds(selectedTagIds); - }, []); - - const handleSearchSubmit = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - setPage(1); - } - }; - - const handlePageChange = (page: number) => { - scroll.top(); - setPage(page); - }; + sortingOption, + selectedTagIds, + handleKeywordChange, + handleCategoryMenuClick, + handleTagMenuClick, + handleSearchSubmit, + handlePageChange, + } = useFilteredTemplateList(); - const handleAllSelected = () => { - if (selectedList.length === templateList.length) { - setSelectedList([]); - - return; - } - - setSelectedList(templateList.map((template) => template.id)); - }; - - const handleDelete = () => { - deleteTemplates(); - toggleIsEditMode(); - toggleDeleteModal(); - }; - - const renderTemplateContent = () => { - if (templateList.length === 0) { - if (debouncedKeyword !== '') { - return ; - } else { - return ; - } - } - - return ( - - ); - }; + const { + isEditMode, + toggleIsEditMode, + isDeleteModalOpen, + toggleDeleteModal, + selectedList, + setSelectedList, + handleAllSelected, + handleDelete, + } = useSelectAndDeleteTemplateList({ templateList }); return ( - + - - - + }> + + - - {isEditMode ? ( - - - - - - ) : ( - - )} - + @@ -155,10 +89,23 @@ const MyTemplatePage = () => { getOptionLabel={(option) => option.value} /> - {tagList.length !== 0 && ( - - )} - {renderTemplateContent()} + + }> + + + + + {isTemplateListFetching && } + {!isTemplateListLoading && ( + + )} + {templateList.length !== 0 && ( @@ -166,14 +113,6 @@ const MyTemplatePage = () => { )} - - {isDeleteModalOpen && ( - - )} @@ -181,67 +120,4 @@ const MyTemplatePage = () => { ); }; -interface TopBannerProps { - name: string; -} - -const TopBanner = ({ name }: TopBannerProps) => ( - - - {name} - - {`${name ? '님' : ''}의 템플릿 입니다 :)`} - - - -); - -const NewTemplateButton = () => { - const navigate = useCustomNavigate(); - - return ( - navigate(END_POINTS.TEMPLATES_UPLOAD)}> - - - 이곳을 눌러 새 템플릿을 추가해보세요 :) - - - ); -}; - export default MyTemplatePage; - -interface ConfirmDeleteModalProps { - isDeleteModalOpen: boolean; - toggleDeleteModal: () => void; - handleDelete: () => void; -} - -const ConfirmDeleteModal = ({ isDeleteModalOpen, toggleDeleteModal, handleDelete }: ConfirmDeleteModalProps) => ( - - - - - 정말 삭제하시겠습니까? - - 삭제된 템플릿은 복구할 수 없습니다. - - - - - - - -); - -const ScrollTopButton = () => ( - { - scroll.top('smooth'); - }} - > - - -); diff --git a/frontend/src/pages/MyTemplatesPage/components/CategoryListSection/CategoryListSection.style.ts b/frontend/src/pages/MyTemplatesPage/components/CategoryListSection/CategoryListSection.style.ts new file mode 100644 index 000000000..0bd0d77ad --- /dev/null +++ b/frontend/src/pages/MyTemplatesPage/components/CategoryListSection/CategoryListSection.style.ts @@ -0,0 +1,8 @@ +import styled from '@emotion/styled'; + +export const CategoryListSectionContainer = styled.div` + display: flex; + flex-direction: column; + gap: 2.5rem; + margin-top: 4.5rem; +`; diff --git a/frontend/src/pages/MyTemplatesPage/components/CategoryListSection/CategoryListSection.tsx b/frontend/src/pages/MyTemplatesPage/components/CategoryListSection/CategoryListSection.tsx new file mode 100644 index 000000000..153b7a22b --- /dev/null +++ b/frontend/src/pages/MyTemplatesPage/components/CategoryListSection/CategoryListSection.tsx @@ -0,0 +1,21 @@ +import { useCategoryListQuery } from '@/queries/categories'; + +import { CategoryFilterMenu } from '..'; +import * as S from './CategoryListSection.style'; + +interface Props { + onSelectCategory: (selectedCategoryId: number) => void; +} + +const CategoryListSection = ({ onSelectCategory }: Props) => { + const { data: categoryData } = useCategoryListQuery(); + const categoryList = categoryData?.categories || []; + + return ( + + + + ); +}; + +export default CategoryListSection; diff --git a/frontend/src/pages/MyTemplatesPage/components/CategoryListSection/CategoryListSectionSkeleton.tsx b/frontend/src/pages/MyTemplatesPage/components/CategoryListSection/CategoryListSectionSkeleton.tsx new file mode 100644 index 000000000..3b54d0595 --- /dev/null +++ b/frontend/src/pages/MyTemplatesPage/components/CategoryListSection/CategoryListSectionSkeleton.tsx @@ -0,0 +1,52 @@ +import styled from '@emotion/styled'; + +import { SettingIcon } from '@/assets/images'; +import { Flex } from '@/components'; +import { useWindowWidth } from '@/hooks'; + +const SkeletonButtonWrapper = styled.div` + display: flex; + align-items: center; + justify-content: center; + + width: 12.5rem; + height: 3rem; /* CategoryButton과 동일한 높이 */ + padding: 0.5rem; + + background: linear-gradient(90deg, #e0e0e0 25%, #c0c0c0 50%, #e0e0e0 75%); + background-size: 200% 100%; + border-radius: 8px; + box-shadow: 1px 2px 8px #00000020; /* CategoryButton과 동일한 그림자 */ + + animation: skeleton-loading 1.5s infinite ease-in-out; + + @keyframes skeleton-loading { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } + } +`; + +const CategoryListSectionSkeleton = () => { + const windowWidth = useWindowWidth(); + + if (windowWidth <= 768) { + return null; + } + + return ( + + + + + + + + + ); +}; + +export default CategoryListSectionSkeleton; diff --git a/frontend/src/pages/MyTemplatesPage/components/ConfirmDeleteModal/ConfirmDeleteModal.tsx b/frontend/src/pages/MyTemplatesPage/components/ConfirmDeleteModal/ConfirmDeleteModal.tsx new file mode 100644 index 000000000..ca721c98b --- /dev/null +++ b/frontend/src/pages/MyTemplatesPage/components/ConfirmDeleteModal/ConfirmDeleteModal.tsx @@ -0,0 +1,26 @@ +import { Button, Modal, Text } from '@/components'; + +interface Props { + isDeleteModalOpen: boolean; + toggleDeleteModal: () => void; + handleDelete: () => void; +} + +const ConfirmDeleteModal = ({ isDeleteModalOpen, toggleDeleteModal, handleDelete }: Props) => ( + + + + 정말 삭제하시겠습니까? + + 삭제된 템플릿은 복구할 수 없습니다. + + + + + + +); + +export default ConfirmDeleteModal; diff --git a/frontend/src/pages/MyTemplatesPage/components/NewTemplateButton/NewTemplateButton.style.ts b/frontend/src/pages/MyTemplatesPage/components/NewTemplateButton/NewTemplateButton.style.ts new file mode 100644 index 000000000..b0e6911b3 --- /dev/null +++ b/frontend/src/pages/MyTemplatesPage/components/NewTemplateButton/NewTemplateButton.style.ts @@ -0,0 +1,23 @@ +import styled from '@emotion/styled'; + +import { theme } from '@/style/theme'; + +export const NewTemplateButtonWrapper = styled.button` + cursor: pointer; + + display: flex; + flex-direction: column; + gap: 1.5rem; + align-items: center; + justify-content: center; + + width: 100%; + height: 19.75rem; + padding: 1.5rem; + + border: 3px dashed ${theme.color.light.primary_500}; + border-radius: 8px; + &:hover { + background-color: ${theme.color.light.primary_50}; + } +`; diff --git a/frontend/src/pages/MyTemplatesPage/components/NewTemplateButton/NewTemplateButton.tsx b/frontend/src/pages/MyTemplatesPage/components/NewTemplateButton/NewTemplateButton.tsx new file mode 100644 index 000000000..c0c9c197c --- /dev/null +++ b/frontend/src/pages/MyTemplatesPage/components/NewTemplateButton/NewTemplateButton.tsx @@ -0,0 +1,22 @@ +import { PlusIcon } from '@/assets/images'; +import { Text } from '@/components'; +import { useCustomNavigate } from '@/hooks'; +import { END_POINTS } from '@/routes'; +import { theme } from '@/style/theme'; + +import * as S from './NewTemplateButton.style'; + +const NewTemplateButton = () => { + const navigate = useCustomNavigate(); + + return ( + navigate(END_POINTS.TEMPLATES_UPLOAD)}> + + + 이곳을 눌러 새 템플릿을 추가해보세요 :) + + + ); +}; + +export default NewTemplateButton; diff --git a/frontend/src/pages/MyTemplatesPage/components/TagListSection/TagListSection.tsx b/frontend/src/pages/MyTemplatesPage/components/TagListSection/TagListSection.tsx new file mode 100644 index 000000000..f90f5f50a --- /dev/null +++ b/frontend/src/pages/MyTemplatesPage/components/TagListSection/TagListSection.tsx @@ -0,0 +1,23 @@ +import { useTagListQuery } from '@/queries/tags'; + +import { TagFilterMenu } from '../'; + +interface Props { + selectedTagIds: number[]; + handleTagMenuClick: (selectedTagIds: number[]) => void; +} + +const TagListSection = ({ selectedTagIds, handleTagMenuClick }: Props) => { + const { data: tagData } = useTagListQuery(); + const tagList = tagData?.tags || []; + + return ( +
+ {tagList.length !== 0 && ( + + )} +
+ ); +}; + +export default TagListSection; diff --git a/frontend/src/pages/MyTemplatesPage/components/TagListSection/TagListSectionSkeleton.tsx b/frontend/src/pages/MyTemplatesPage/components/TagListSection/TagListSectionSkeleton.tsx new file mode 100644 index 000000000..cd6c36a31 --- /dev/null +++ b/frontend/src/pages/MyTemplatesPage/components/TagListSection/TagListSectionSkeleton.tsx @@ -0,0 +1,28 @@ +import styled from '@emotion/styled'; + +const skeletonAnimation = ` + @keyframes skeleton-loading { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } + } +`; + +const TagListSectionSkeleton = styled.div` + ${skeletonAnimation} + box-sizing: content-box; + width: 100%; + height: 3.875rem; + + background: linear-gradient(90deg, #e0e0e0 25%, #c0c0c0 50%, #e0e0e0 75%); + background-size: 200% 100%; + border: 1px solid #c0c0c0; + border-radius: 8px; + + animation: skeleton-loading 2.5s infinite ease-in-out; +`; + +export default TagListSectionSkeleton; diff --git a/frontend/src/pages/MyTemplatesPage/components/TemplateDeleteSelection/TemplateDeleteSelection.tsx b/frontend/src/pages/MyTemplatesPage/components/TemplateDeleteSelection/TemplateDeleteSelection.tsx new file mode 100644 index 000000000..3c27729b2 --- /dev/null +++ b/frontend/src/pages/MyTemplatesPage/components/TemplateDeleteSelection/TemplateDeleteSelection.tsx @@ -0,0 +1,60 @@ +import { Button, Flex } from '@/components'; + +import { ConfirmDeleteModal } from '../'; + +interface Props { + isEditMode: boolean; + toggleIsEditMode: () => void; + isDeleteModalOpen: boolean; + toggleDeleteModal: () => void; + handleAllSelected: () => void; + selectedListLength: number; + templateListLength: number; + handleDelete: () => void; +} + +const TemplateDeleteSelection = ({ + isEditMode, + toggleIsEditMode, + isDeleteModalOpen, + toggleDeleteModal, + handleAllSelected, + selectedListLength, + templateListLength, + handleDelete, +}: Props) => ( + <> + + {isEditMode ? ( + + + + + + ) : ( + + )} + + {isDeleteModalOpen && ( + + )} + +); + +export default TemplateDeleteSelection; diff --git a/frontend/src/pages/MyTemplatesPage/components/TemplateListSection/TemplateListSection.tsx b/frontend/src/pages/MyTemplatesPage/components/TemplateListSection/TemplateListSection.tsx new file mode 100644 index 000000000..0764865a9 --- /dev/null +++ b/frontend/src/pages/MyTemplatesPage/components/TemplateListSection/TemplateListSection.tsx @@ -0,0 +1,35 @@ +import { NoSearchResults } from '@/components'; +import { useWindowWidth } from '@/hooks'; +import { TemplateListItem } from '@/types'; + +import { NewTemplateButton, TemplateGrid } from '../'; + +interface Props { + templateList: TemplateListItem[]; + isEditMode: boolean; + isSearching: boolean; + selectedList: number[]; + setSelectedList: React.Dispatch>; +} + +const getGridCols = (windowWidth: number) => (windowWidth <= 1024 ? 1 : 2); + +const TemplateListSection = ({ templateList, isSearching, isEditMode, selectedList, setSelectedList }: Props) => { + const windowWidth = useWindowWidth(); + + if (templateList.length === 0) { + return isSearching ? : ; + } + + return ( + + ); +}; + +export default TemplateListSection; diff --git a/frontend/src/pages/MyTemplatesPage/components/TemplateListSection/TemplateListSectionLoading.style.ts b/frontend/src/pages/MyTemplatesPage/components/TemplateListSection/TemplateListSectionLoading.style.ts new file mode 100644 index 000000000..3de81a5b4 --- /dev/null +++ b/frontend/src/pages/MyTemplatesPage/components/TemplateListSection/TemplateListSectionLoading.style.ts @@ -0,0 +1,23 @@ +import styled from '@emotion/styled'; + +export const LoadingOverlay = styled.div` + position: absolute; + z-index: 1000; + top: 0; + right: 0; + bottom: 0; + left: 0; + + display: flex; + align-items: flex-start; + justify-content: center; + + padding-top: 1rem; + + background-color: rgba(255, 255, 255, 0.7); +`; + +export const LoadingBallWrapper = styled.div` + width: 100%; + margin-top: 10rem; +`; diff --git a/frontend/src/pages/MyTemplatesPage/components/TemplateListSection/TemplateListSectionLoading.tsx b/frontend/src/pages/MyTemplatesPage/components/TemplateListSection/TemplateListSectionLoading.tsx new file mode 100644 index 000000000..383eed2b9 --- /dev/null +++ b/frontend/src/pages/MyTemplatesPage/components/TemplateListSection/TemplateListSectionLoading.tsx @@ -0,0 +1,13 @@ +import { LoadingBall } from '@/components'; + +import * as S from './TemplateListSectionLoading.style'; + +const TemplateListSectionLoading = () => ( + + + + + +); + +export default TemplateListSectionLoading; diff --git a/frontend/src/pages/MyTemplatesPage/components/TopBanner/TopBanner.style.ts b/frontend/src/pages/MyTemplatesPage/components/TopBanner/TopBanner.style.ts new file mode 100644 index 000000000..1b0dc70bb --- /dev/null +++ b/frontend/src/pages/MyTemplatesPage/components/TopBanner/TopBanner.style.ts @@ -0,0 +1,18 @@ +import styled from '@emotion/styled'; + +export const TopBannerContainer = styled.div` + display: flex; + align-items: center; + + width: 100%; + height: 10.25rem; + + white-space: nowrap; +`; + +export const TopBannerTextWrapper = styled.div` + display: flex; + gap: 0.5rem; + align-items: center; + margin-left: calc(12.5rem + clamp(1rem, calc(0.0888 * 100vw - 3.2618rem), 4.375rem)); +`; diff --git a/frontend/src/pages/MyTemplatesPage/components/TopBanner/TopBanner.tsx b/frontend/src/pages/MyTemplatesPage/components/TopBanner/TopBanner.tsx new file mode 100644 index 000000000..4c2cf5b25 --- /dev/null +++ b/frontend/src/pages/MyTemplatesPage/components/TopBanner/TopBanner.tsx @@ -0,0 +1,21 @@ +import { Heading } from '@/components'; +import { theme } from '@/style/theme'; + +import * as S from './TopBanner.style'; + +interface Props { + name: string; +} + +const TopBanner = ({ name }: Props) => ( + + + {name} + + {`${name ? '님' : ''}의 템플릿 입니다 :)`} + + + +); + +export default TopBanner; diff --git a/frontend/src/pages/MyTemplatesPage/components/index.ts b/frontend/src/pages/MyTemplatesPage/components/index.ts index e5621c200..f93c5f729 100644 --- a/frontend/src/pages/MyTemplatesPage/components/index.ts +++ b/frontend/src/pages/MyTemplatesPage/components/index.ts @@ -2,3 +2,13 @@ export { default as CategoryEditModal } from './CategoryEditModal/CategoryEditMo export { default as CategoryFilterMenu } from './CategoryFilterMenu/CategoryFilterMenu'; export { default as TagFilterMenu } from './TagFilterMenu/TagFilterMenu'; export { default as TemplateGrid } from './TemplateGrid/TemplateGrid'; +export { default as TopBanner } from './TopBanner/TopBanner'; +export { default as ConfirmDeleteModal } from './ConfirmDeleteModal/ConfirmDeleteModal'; +export { default as NewTemplateButton } from './NewTemplateButton/NewTemplateButton'; +export { default as CategoryListSection } from './CategoryListSection/CategoryListSection'; +export { default as CategoryListSectionSkeleton } from './CategoryListSection/CategoryListSectionSkeleton'; +export { default as TagListSection } from './TagListSection/TagListSection'; +export { default as TagListSectionSkeleton } from './TagListSection/TagListSectionSkeleton'; +export { default as TemplateListSection } from './TemplateListSection/TemplateListSection'; +export { default as TemplateListSectionLoading } from './TemplateListSection/TemplateListSectionLoading'; +export { default as TemplateDeleteSelection } from './TemplateDeleteSelection/TemplateDeleteSelection'; diff --git a/frontend/src/pages/MyTemplatesPage/hooks/.gitkeep b/frontend/src/pages/MyTemplatesPage/hooks/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/src/pages/MyTemplatesPage/hooks/index.ts b/frontend/src/pages/MyTemplatesPage/hooks/index.ts new file mode 100644 index 000000000..4825988ac --- /dev/null +++ b/frontend/src/pages/MyTemplatesPage/hooks/index.ts @@ -0,0 +1,2 @@ +export { useFilteredTemplateList } from './useFilteredTemplateList'; +export { useSelectAndDeleteTemplateList } from './useSelectAndDeleteTemplateList'; diff --git a/frontend/src/pages/MyTemplatesPage/hooks/useFilteredTemplateList.ts b/frontend/src/pages/MyTemplatesPage/hooks/useFilteredTemplateList.ts new file mode 100644 index 000000000..e7082fdf7 --- /dev/null +++ b/frontend/src/pages/MyTemplatesPage/hooks/useFilteredTemplateList.ts @@ -0,0 +1,69 @@ +import { useCallback, useState } from 'react'; + +import { DEFAULT_SORTING_OPTION } from '@/api'; +import { useDropdown } from '@/hooks'; +import { useSearchKeyword } from '@/hooks/template'; +import { useTemplateListQuery } from '@/queries/templates'; +import { scroll } from '@/utils'; + +const FIRST_PAGE = 1; + +export const useFilteredTemplateList = () => { + const [selectedCategoryId, setSelectedCategoryId] = useState(undefined); + const [selectedTagIds, setSelectedTagIds] = useState([]); + const { keyword, debouncedKeyword, handleKeywordChange } = useSearchKeyword(); + const { currentValue: sortingOption, ...dropdownProps } = useDropdown(DEFAULT_SORTING_OPTION); + const [page, setPage] = useState(FIRST_PAGE); + + const { + data: templateData, + isFetching: isTemplateListFetching, + isLoading: isTemplateListLoading, + } = useTemplateListQuery({ + categoryId: selectedCategoryId, + tagIds: selectedTagIds, + keyword: debouncedKeyword, + sort: sortingOption.key, + page, + }); + + const templateList = templateData?.templates || []; + const totalPages = templateData?.totalPages || 0; + + const handleCategoryMenuClick = useCallback((selectedCategoryId: number) => { + setSelectedCategoryId(selectedCategoryId); + handlePageChange(FIRST_PAGE); + }, []); + + const handleTagMenuClick = useCallback((selectedTagIds: number[]) => { + setSelectedTagIds(selectedTagIds); + }, []); + + const handleSearchSubmit = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handlePageChange(FIRST_PAGE); + } + }; + + const handlePageChange = (page: number) => { + scroll.top('smooth'); + setPage(page); + }; + + return { + templateList, + isTemplateListFetching, + isTemplateListLoading, + totalPages, + dropdownProps, + keyword, + page, + sortingOption, + selectedTagIds, + handleKeywordChange, + handleCategoryMenuClick, + handleTagMenuClick, + handleSearchSubmit, + handlePageChange, + }; +}; diff --git a/frontend/src/pages/MyTemplatesPage/hooks/useSelectAndDeleteTemplateList.ts b/frontend/src/pages/MyTemplatesPage/hooks/useSelectAndDeleteTemplateList.ts new file mode 100644 index 000000000..59751cd13 --- /dev/null +++ b/frontend/src/pages/MyTemplatesPage/hooks/useSelectAndDeleteTemplateList.ts @@ -0,0 +1,44 @@ +import { useState } from 'react'; + +import { useToggle } from '@/hooks'; +import { useTemplateDeleteMutation } from '@/queries/templates'; +import { TemplateListItem } from '@/types'; + +interface Props { + templateList: TemplateListItem[]; +} + +export const useSelectAndDeleteTemplateList = ({ templateList }: Props) => { + const [isEditMode, toggleIsEditMode] = useToggle(); + const [selectedList, setSelectedList] = useState([]); + const [isDeleteModalOpen, toggleDeleteModal] = useToggle(); + + const { mutateAsync: deleteTemplates } = useTemplateDeleteMutation(selectedList); + + const handleAllSelected = () => { + if (selectedList.length === templateList.length) { + setSelectedList([]); + + return; + } + + setSelectedList(templateList.map((template) => template.id)); + }; + + const handleDelete = () => { + deleteTemplates(); + toggleIsEditMode(); + toggleDeleteModal(); + }; + + return { + isEditMode, + toggleIsEditMode, + isDeleteModalOpen, + toggleDeleteModal, + selectedList, + setSelectedList, + handleAllSelected, + handleDelete, + }; +}; diff --git a/frontend/src/queries/categories/useCategoryListQuery.ts b/frontend/src/queries/categories/useCategoryListQuery.ts index 56da9ec05..2acc44b92 100644 --- a/frontend/src/queries/categories/useCategoryListQuery.ts +++ b/frontend/src/queries/categories/useCategoryListQuery.ts @@ -1,4 +1,4 @@ -import { useQuery } from '@tanstack/react-query'; +import { useSuspenseQuery } from '@tanstack/react-query'; import { QUERY_KEY, getCategoryList } from '@/api'; import { useAuth } from '@/hooks/authentication/useAuth'; @@ -9,8 +9,14 @@ export const useCategoryListQuery = () => { memberInfo: { memberId }, } = useAuth(); - return useQuery({ + const result = useSuspenseQuery({ queryKey: [QUERY_KEY.CATEGORY_LIST], queryFn: () => getCategoryList({ memberId }), }); + + if (result.error && !result.isFetching) { + throw result.error; + } + + return result; }; diff --git a/frontend/src/queries/tags/useTagListQuery.ts b/frontend/src/queries/tags/useTagListQuery.ts index 62e9d3cce..2163bdade 100644 --- a/frontend/src/queries/tags/useTagListQuery.ts +++ b/frontend/src/queries/tags/useTagListQuery.ts @@ -1,4 +1,4 @@ -import { useQuery } from '@tanstack/react-query'; +import { useSuspenseQuery } from '@tanstack/react-query'; import { QUERY_KEY, getTagList } from '@/api'; import { useAuth } from '@/hooks/authentication/useAuth'; @@ -9,9 +9,14 @@ export const useTagListQuery = () => { memberInfo: { memberId }, } = useAuth(); - return useQuery({ + const result = useSuspenseQuery({ queryKey: [QUERY_KEY.TAG_LIST], queryFn: () => getTagList({ memberId }), - throwOnError: true, }); + + if (result.error && !result.isFetching) { + throw result.error; + } + + return result; }; diff --git a/frontend/src/queries/templates/index.ts b/frontend/src/queries/templates/index.ts index 9a21c0c1a..f3e5707d6 100644 --- a/frontend/src/queries/templates/index.ts +++ b/frontend/src/queries/templates/index.ts @@ -4,4 +4,3 @@ export { useTemplateQuery } from './useTemplateQuery'; export { useTemplateUploadMutation } from './useTemplateUploadMutation'; export { useTemplateEditMutation } from './useTemplateEditMutation'; export { useTemplateDeleteMutation } from './useTemplateDeleteMutation'; -export { useTemplateCategoryTagQueries } from './useTemplateCategoryTagQueries'; diff --git a/frontend/src/queries/templates/useTemplateCategoryTagQueries.ts b/frontend/src/queries/templates/useTemplateCategoryTagQueries.ts deleted file mode 100644 index 4b047567d..000000000 --- a/frontend/src/queries/templates/useTemplateCategoryTagQueries.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { useSuspenseQueries } from '@tanstack/react-query'; - -import { QUERY_KEY, getTemplateList, getTagList, getCategoryList, DEFAULT_SORTING_OPTION, PAGE_SIZE } from '@/api'; -import { useAuth } from '@/hooks/authentication/useAuth'; -import type { SortingKey } from '@/types'; - -interface Props { - keyword?: string; - categoryId?: number; - tagIds?: number[]; - sort?: SortingKey; - page?: number; - size?: number; -} - -export const useTemplateCategoryTagQueries = ({ - keyword, - categoryId, - tagIds, - sort = DEFAULT_SORTING_OPTION.key, - page = 1, - size = PAGE_SIZE, -}: Props) => { - const { - memberInfo: { memberId }, - } = useAuth(); - - return useSuspenseQueries({ - queries: [ - { - queryKey: [QUERY_KEY.TEMPLATE_LIST, keyword, categoryId, tagIds, sort, page, size, memberId], - queryFn: () => getTemplateList({ keyword, categoryId, tagIds, sort, page, size, memberId }), - }, - { - queryKey: [QUERY_KEY.CATEGORY_LIST], - queryFn: () => getCategoryList({ memberId }), - }, - { - queryKey: [QUERY_KEY.TAG_LIST], - queryFn: () => getTagList({ memberId }), - }, - ], - }); -}; diff --git a/frontend/src/routes/router.tsx b/frontend/src/routes/router.tsx index ca7de82dc..3f3073a2c 100644 --- a/frontend/src/routes/router.tsx +++ b/frontend/src/routes/router.tsx @@ -2,7 +2,7 @@ import { ErrorBoundary } from '@sentry/react'; import { lazy, Suspense } from 'react'; import { createBrowserRouter } from 'react-router-dom'; -import { Layout, LoadingFallback } from '@/components'; +import { Layout } from '@/components'; import RouteGuard from './RouteGuard'; import { END_POINTS } from './endPoints'; @@ -46,9 +46,7 @@ const router = createBrowserRouter([ element: ( }> - }> - - + ),