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: (
}>
- }>
-
-
+
),