diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 8d58dd5e..87ba6ea3 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -33,7 +33,15 @@ jobs: run: ./gradlew build - name: Publish Unit Test Results - uses: EnricoMi/publish-unit-test-result-action@v1 - if: ${{ always() }} + uses: EnricoMi/publish-unit-test-result-action@v2 + if: always() with: - files: build/test-results/**/*.xml + junit_files: ${{ github.workspace }}/backend/build/test-results/**/*.xml + + - name: Add coverage to PR + id: jacoco + uses: madrapps/jacoco-report@v1.2 + with: + paths: ${{ github.workspace }}/backend/build/jacoco/index.xml + token: ${{ secrets.GITHUB_TOKEN }} + diff --git a/backend/build.gradle b/backend/build.gradle index 7f26c83a..a58d4cc9 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -122,7 +122,7 @@ sonarqube { property "sonar.sources", "src" property "sonar.language", "java" property "sonar.sourceEncoding", "UTF-8" - property "sonar.profile", "Sonar way" + property "sonar.profile", "Dallog Custom Java Ruleset" property "sonar.java.binaries", "${buildDir}/classes" property "sonar.test.inclusions", "**/*Test.java" property 'sonar.exclusions', '**/jacoco/**' diff --git a/frontend/src/api/profile.ts b/frontend/src/api/profile.ts index 223f41b9..c5d0727e 100644 --- a/frontend/src/api/profile.ts +++ b/frontend/src/api/profile.ts @@ -5,6 +5,7 @@ import dallogApi from './'; const profileApi = { endpoint: { get: '/api/members/me', + delete: '/api/members/me', patch: '/api/members/me', }, headers: { @@ -20,6 +21,14 @@ const profileApi = { return response; }, + delete: async (accessToken: string) => { + const response = await dallogApi.delete(profileApi.endpoint.delete, { + headers: { ...profileApi.headers, Authorization: `Bearer ${accessToken}` }, + }); + + return response; + }, + patch: async (accessToken: string, body: Pick) => { const response = await dallogApi.patch(profileApi.endpoint.patch, body, { headers: { ...profileApi.headers, Authorization: `Bearer ${accessToken}` }, diff --git a/frontend/src/components/FilterCategoryItem/FilterCategoryItem.styles.ts b/frontend/src/components/FilterCategoryItem/FilterCategoryItem.styles.ts deleted file mode 100644 index dcdb8d3c..00000000 --- a/frontend/src/components/FilterCategoryItem/FilterCategoryItem.styles.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { css, Theme } from '@emotion/react'; - -const itemStyle = ({ colors, flex }: Theme) => css` - ${flex.row} - - justify-content: space-between; - - width: 100%; - height: 8rem; - - &:hover { - background-color: ${colors.GRAY_100}; - - button { - visibility: visible; - } - } -`; - -const checkBoxNameStyle = ({ flex }: Theme) => css` - ${flex.row} - - gap: 1rem; - - &:hover { - cursor: pointer; - } -`; - -const nameStyle = css` - overflow: hidden; - position: relative; - - width: 32rem; - - white-space: nowrap; - text-overflow: ellipsis; -`; - -const colorStyle = (color: string) => css` - width: 5rem; - height: 5rem; - border-radius: 50%; - - background: ${color}; - - &:hover { - filter: none; - transform: scale(1.2); - } -`; - -const headerStyle = css` - padding: 2rem; - - font-size: 5rem; -`; - -const iconStyle = css` - visibility: hidden; -`; - -const paletteStyle = ({ colors }: Theme) => css` - position: absolute; - right: 0; - z-index: 30; - - display: grid; - grid-template-columns: repeat(4, 1fr); - place-items: center; - gap: 2rem; - - width: 35rem; - padding: 2rem; - border: 1px solid ${colors.GRAY_300}; - border-radius: 4px; - - background: ${colors.WHITE}; -`; - -const paletteLayoutStyle = css` - position: relative; -`; - -const outerStyle = css` - position: fixed; - left: 0; - top: 16rem; - z-index: 20; - - width: 100%; - height: 100%; - - background-color: transparent; -`; - -const grayTextStyle = ({ colors }: Theme) => css` - color: ${colors.GRAY_600}; -`; - -export { - itemStyle, - colorStyle, - checkBoxNameStyle, - grayTextStyle, - headerStyle, - iconStyle, - nameStyle, - outerStyle, - paletteStyle, - paletteLayoutStyle, -}; diff --git a/frontend/src/components/FilterCategoryList/FilterCategoryList.fallback.stories.tsx b/frontend/src/components/FilterCategoryList/FilterCategoryList.fallback.stories.tsx deleted file mode 100644 index 7d761a95..00000000 --- a/frontend/src/components/FilterCategoryList/FilterCategoryList.fallback.stories.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { ComponentMeta, ComponentStory } from '@storybook/react'; - -import FilterCategoryListFallback from './FilterCategoryList.fallback'; - -export default { - title: 'Components/FilterCategoryListFallback', - component: FilterCategoryListFallback, -} as ComponentMeta; - -const Template: ComponentStory = () => ( - -); - -const Primary = Template.bind({}); - -export { Primary }; diff --git a/frontend/src/components/FilterCategoryList/FilterCategoryList.fallback.tsx b/frontend/src/components/FilterCategoryList/FilterCategoryList.fallback.tsx deleted file mode 100644 index ff5a7e8d..00000000 --- a/frontend/src/components/FilterCategoryList/FilterCategoryList.fallback.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { useTheme } from '@emotion/react'; - -import Skeleton from '@/components/@common/Skeleton/Skeleton'; - -import { headerStyle, skeletonListStyle, skeletonStyle } from './FilterCategoryList.styles'; - -function FilterCategoryFallback() { - const theme = useTheme(); - - return ( -
- 구독 카테고리 -
- {new Array(10).fill(0).map((el, index) => ( - - ))} -
-
- ); -} - -export default FilterCategoryFallback; diff --git a/frontend/src/components/FilterCategoryList/FilterCategoryList.styles.ts b/frontend/src/components/FilterCategoryList/FilterCategoryList.styles.ts deleted file mode 100644 index d297352e..00000000 --- a/frontend/src/components/FilterCategoryList/FilterCategoryList.styles.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { css, Theme } from '@emotion/react'; - -const listStyle = ({ flex }: Theme, isSideBarOpen: boolean) => css` - ${flex.column} - - display: ${isSideBarOpen ? 'flex' : 'none'}; - justify-content: flex-start; - - width: 54rem; - - font-size: 4rem; -`; - -const headerStyle = ({ flex }: Theme) => css` - ${flex.row} - - justify-content: space-between; - - width: 100%; - height: 8rem; - - font-weight: bold; -`; - -const googleImportButtonStyle = ({ colors, flex }: Theme) => css` - ${flex.row} - - position: relative; - - width: 100%; - height: 11rem; - padding: 4rem; - margin: 2rem 0 3rem; - border-radius: 4px; - border: 1px solid ${colors.GRAY_600}; - - background: ${colors.WHITE}; - - font-size: 4rem; - color: ${colors.GRAY_600}; - - &:hover { - filter: none; - } -`; - -const googleImportTextStyle = css` - width: 100%; -`; - -const contentStyle = css` - display: flex; - flex-direction: column; - gap: 2rem; - - width: 100%; -`; - -const skeletonStyle = ({ flex }: Theme) => css` - ${flex.column}; - - gap: 5rem; -`; - -const skeletonListStyle = ({ flex }: Theme, isSideBarOpen: boolean) => css` - ${flex.column}; - - display: ${isSideBarOpen ? 'flex' : 'none'}; - justify-content: flex-start; - - width: 54rem; - margin-top: 16rem; - - font-size: 4rem; -`; - -export { - contentStyle, - googleImportButtonStyle, - googleImportTextStyle, - headerStyle, - listStyle, - skeletonStyle, - skeletonListStyle, -}; diff --git a/frontend/src/components/FilterCategoryList/FilterCategoryList.tsx b/frontend/src/components/FilterCategoryList/FilterCategoryList.tsx deleted file mode 100644 index 81b0ffd7..00000000 --- a/frontend/src/components/FilterCategoryList/FilterCategoryList.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { useTheme } from '@emotion/react'; -import { AxiosError, AxiosResponse } from 'axios'; -import { useQuery } from 'react-query'; -import { useRecoilValue } from 'recoil'; - -import useToggle from '@/hooks/useToggle'; - -import { SubscriptionType } from '@/@types/subscription'; - -import { sideBarState, userState } from '@/recoil/atoms'; - -import Button from '@/components/@common/Button/Button'; -import ModalPortal from '@/components/@common/ModalPortal/ModalPortal'; -import FilterCategoryItem from '@/components/FilterCategoryItem/FilterCategoryItem'; -import GoogleImportModal from '@/components/GoogleImportModal/GoogleImportModal'; - -import { CACHE_KEY } from '@/constants/api'; - -import subscriptionApi from '@/api/subscription'; - -import { FcGoogle } from 'react-icons/fc'; - -import FilterCategoryFallback from './FilterCategoryList.fallback'; -import { - contentStyle, - googleImportButtonStyle, - googleImportTextStyle, - headerStyle, - listStyle, -} from './FilterCategoryList.styles'; - -function FilterCategoryList() { - const { accessToken } = useRecoilValue(userState); - const isSideBarOpen = useRecoilValue(sideBarState); - - const theme = useTheme(); - - const { state: isGoogleImportModalOpen, toggleState: toggleGoogleImportModalOpen } = useToggle(); - - const { isLoading, data } = useQuery, AxiosError>( - CACHE_KEY.SUBSCRIPTIONS, - () => subscriptionApi.get(accessToken) - ); - - if (isLoading || data === undefined) { - return ; - } - - const handleClickGoogleImportButton = () => { - toggleGoogleImportModalOpen(); - }; - - return ( -
- 구독 카테고리 - -
- {data?.data.map((el) => { - return ; - })} -
- - - -
- ); -} - -export default FilterCategoryList; diff --git a/frontend/src/components/Profile/Profile.styles.ts b/frontend/src/components/Profile/Profile.styles.ts index f6913f5c..5790a2de 100644 --- a/frontend/src/components/Profile/Profile.styles.ts +++ b/frontend/src/components/Profile/Profile.styles.ts @@ -57,6 +57,15 @@ const logoutButtonStyle = ({ colors }: Theme) => css` font-size: 3rem; `; +const withdrawalButtonStyle = ({ colors }: Theme) => css` + padding: 2rem 3rem; + border: 1px solid ${colors.RED_400}; + border-radius: 3px; + + font-size: 3rem; + color: ${colors.RED_400}; +`; + const menu = ({ colors }: Theme) => css` position: relative; @@ -126,4 +135,5 @@ export { nameStyle, nameButtonStyle, skeletonStyle, + withdrawalButtonStyle, }; diff --git a/frontend/src/components/Profile/Profile.tsx b/frontend/src/components/Profile/Profile.tsx index da150d39..11457bc1 100644 --- a/frontend/src/components/Profile/Profile.tsx +++ b/frontend/src/components/Profile/Profile.tsx @@ -1,15 +1,14 @@ -import { AxiosError, AxiosResponse } from 'axios'; import { useRef, useState } from 'react'; -import { useMutation, useQuery, useQueryClient } from 'react-query'; +import { useMutation, useQueryClient } from 'react-query'; import { useNavigate } from 'react-router-dom'; -import { useRecoilValue } from 'recoil'; -import { ProfileType } from '@/@types/profile'; - -import { userState } from '@/recoil/atoms'; +import useToggle from '@/hooks/useToggle'; +import useUserValue from '@/hooks/useUserValue'; import Button from '@/components/@common/Button/Button'; import Fieldset from '@/components/@common/Fieldset/Fieldset'; +import ModalPortal from '@/components/@common/ModalPortal/ModalPortal'; +import WithdrawalModal from '@/components/WithdrawalModal/WithdrawalModal'; import { PATH } from '@/constants'; import { CACHE_KEY } from '@/constants/api'; @@ -34,26 +33,23 @@ import { menuTitle, nameButtonStyle, nameStyle, + withdrawalButtonStyle, } from './Profile.styles'; function Profile() { + const navigate = useNavigate(); + + const { user } = useUserValue(); + const [isEditingName, setEditingName] = useState(false); const inputRef = { displayName: useRef(null), }; - const navigate = useNavigate(); - - const { accessToken } = useRecoilValue(userState); - const queryClient = useQueryClient(); - const { data } = useQuery, AxiosError>(CACHE_KEY.PROFILE, () => - profileApi.get(accessToken) - ); - const { mutate } = useMutation( - (body: { displayName: string }) => profileApi.patch(accessToken, body), + (body: { displayName: string }) => profileApi.patch(user.accessToken, body), { onSuccess: () => { queryClient.invalidateQueries(CACHE_KEY.PROFILE); @@ -62,6 +58,8 @@ function Profile() { } ); + const { state: isWithdrawalModalOpen, toggleState: toggleWithdrawalModalOpen } = useToggle(); + const handleClickModifyButton = () => { setEditingName(true); }; @@ -91,22 +89,21 @@ function Profile() { return (
- 프로필 이미지 + 프로필 이미지
{isEditingName ? (
)} - {data?.data.email} + {user.email}
+ + + + + + ); } diff --git a/frontend/src/components/ScheduleAddModal/ScheduleAddModal.styles.ts b/frontend/src/components/ScheduleAddModal/ScheduleAddModal.styles.ts index 9f0d837e..a42993b6 100644 --- a/frontend/src/components/ScheduleAddModal/ScheduleAddModal.styles.ts +++ b/frontend/src/components/ScheduleAddModal/ScheduleAddModal.styles.ts @@ -16,27 +16,62 @@ const form = ({ flex }: Theme) => css` height: 100%; `; -const allDayButton = ({ colors }: Theme, isAllDay: boolean) => css` - width: 100%; - height: 9rem; - border: 1px solid ${colors.GRAY_500}; - border-radius: 8px; - filter: drop-shadow(0 2px 2px ${colors.GRAY_400}); - - background: ${isAllDay ? colors.YELLOW_500 : colors.WHITE}; - - font-size: 5rem; - color: ${isAllDay ? colors.WHITE : colors.GRAY_600}; -`; - const dateTime = ({ flex }: Theme) => css` ${flex.column} + position: relative; gap: 2.5rem; width: 100%; `; +const checkboxStyle = ({ colors, flex }: Theme) => css` + ${flex.row} + + position: absolute; + top: 0; + right: 1rem; + gap: 2rem; + + font-size: 4rem; + color: ${colors.GRAY_700}; + + input + label { + position: relative; + + width: 4rem; + height: 4rem; + border: 1px solid ${colors.YELLOW_500}; + border-radius: 2px; + + &:hover { + cursor: pointer; + } + } + + input:checked + label::after { + content: '✓'; + + position: absolute; + top: -1px; + left: -1px; + + width: 4rem; + height: 4rem; + border-radius: 2px; + + background: ${colors.YELLOW_500}; + + font-weight: 600; + color: white; + text-align: center; + } + + input { + display: none; + } +`; + const arrow = ({ colors }: Theme) => css` font-size: 6rem; font-weight: bold; @@ -102,10 +137,10 @@ const labelStyle = ({ colors }: Theme) => css` `; export { - allDayButton, arrow, categorySelect, cancelButton, + checkboxStyle, controlButtons, dateTime, form, diff --git a/frontend/src/components/ScheduleAddModal/ScheduleAddModal.tsx b/frontend/src/components/ScheduleAddModal/ScheduleAddModal.tsx index bc2195be..f8d94c94 100644 --- a/frontend/src/components/ScheduleAddModal/ScheduleAddModal.tsx +++ b/frontend/src/components/ScheduleAddModal/ScheduleAddModal.tsx @@ -28,10 +28,10 @@ import categoryApi from '@/api/category'; import scheduleApi from '@/api/schedule'; import { - allDayButton, arrow, cancelButton, categorySelect, + checkboxStyle, controlButtons, dateTime, form, @@ -152,15 +152,22 @@ function ScheduleAddModal({ dateInfo, closeModal }: ScheduleAddModalProps) { autoFocus labelText="제목" /> -
+
+ +

, AxiosError>( - CACHE_KEY.PROFILE, - () => profileApi.get(accessToken) - ); - const { data: categoryGetResponse } = useQuery, AxiosError>( CACHE_KEY.CATEGORY, () => categoryApi.getSingle(scheduleInfo.categoryId) ); const { mutate } = useMutation( - () => scheduleApi.delete(accessToken, scheduleInfo.id), + () => scheduleApi.delete(user.accessToken, scheduleInfo.id), { onSuccess: () => onSuccessDeleteSchedule(), } @@ -102,7 +93,7 @@ function ScheduleModal({ const canEditSchedule = (scheduleInfo.categoryType === CATEGORY_TYPE.NORMAL || scheduleInfo.categoryType === CATEGORY_TYPE.PERSONAL) && - profileGetResponse?.data.id === categoryGetResponse?.data.creator.id; + user.id === categoryGetResponse?.data.creator.id; return (
diff --git a/frontend/src/components/ScheduleModifyModal/ScheduleModifyModal.styles.ts b/frontend/src/components/ScheduleModifyModal/ScheduleModifyModal.styles.ts index 0b2753de..60ce89b9 100644 --- a/frontend/src/components/ScheduleModifyModal/ScheduleModifyModal.styles.ts +++ b/frontend/src/components/ScheduleModifyModal/ScheduleModifyModal.styles.ts @@ -33,27 +33,62 @@ const categoryStyle = ({ colors }: Theme, colorCode: string) => css` } `; -const allDayButtonStyle = ({ colors }: Theme, isAllDay: boolean) => css` - width: 100%; - height: 9rem; - border: 1px solid ${colors.GRAY_500}; - border-radius: 8px; - filter: drop-shadow(0 2px 2px ${colors.GRAY_400}); - - background: ${isAllDay ? colors.YELLOW_500 : colors.WHITE}; - - font-size: 5rem; - color: ${isAllDay ? colors.WHITE : colors.GRAY_600}; -`; - const dateTimeStyle = ({ flex }: Theme) => css` ${flex.column} + position: relative; gap: 2.5rem; width: 100%; `; +const checkboxStyle = ({ colors, flex }: Theme) => css` + ${flex.row} + + position: absolute; + top: 0; + right: 1rem; + gap: 2rem; + + font-size: 4rem; + color: ${colors.GRAY_700}; + + input + label { + position: relative; + + width: 4rem; + height: 4rem; + border: 1px solid ${colors.YELLOW_500}; + border-radius: 2px; + + &:hover { + cursor: pointer; + } + } + + input:checked + label::after { + content: '✓'; + + position: absolute; + top: -1px; + left: -1px; + + width: 4rem; + height: 4rem; + border-radius: 2px; + + background: ${colors.YELLOW_500}; + + font-weight: 600; + color: white; + text-align: center; + } + + input { + display: none; + } +`; + const arrowStyle = ({ colors }: Theme) => css` font-size: 6rem; font-weight: bold; @@ -109,10 +144,10 @@ const categoryBoxStyle = ({ flex }: Theme) => css` `; export { - allDayButtonStyle, arrowStyle, cancelButtonStyle, categoryStyle, + checkboxStyle, controlButtonsStyle, dateTimeStyle, formStyle, diff --git a/frontend/src/components/ScheduleModifyModal/ScheduleModifyModal.tsx b/frontend/src/components/ScheduleModifyModal/ScheduleModifyModal.tsx index 88448b37..7958598c 100644 --- a/frontend/src/components/ScheduleModifyModal/ScheduleModifyModal.tsx +++ b/frontend/src/components/ScheduleModifyModal/ScheduleModifyModal.tsx @@ -25,11 +25,11 @@ import categoryApi from '@/api/category'; import scheduleApi from '@/api/schedule'; import { - allDayButtonStyle, arrowStyle, cancelButtonStyle, categoryBoxStyle, categoryStyle, + checkboxStyle, controlButtonsStyle, dateTimeStyle, formStyle, @@ -144,15 +144,22 @@ function ScheduleModifyModal({ scheduleInfo, closeModal }: ScheduleModifyModalPr )} labelText="제목" /> -
+
+ +

; + const { isLoading, data } = useQuery, AxiosError>( + CACHE_KEY.SUBSCRIPTIONS, + () => subscriptionApi.get(user.accessToken), + { + enabled: !!user.accessToken, + } + ); + + if (!user.accessToken || isLoading || data === undefined) { + return ( +
+ +
+ ); } + const subscribedList = data.data.filter((el) => el.category.creator.id !== user.id); + + const myList = data.data.filter( + (el) => el.category.categoryType !== CATEGORY_TYPE.GOOGLE && el.category.creator.id === user.id + ); + + const googleList = data.data.filter((el) => el.category.categoryType === CATEGORY_TYPE.GOOGLE); + return (
- + + +
); } diff --git a/frontend/src/components/SideGoogleList/SideGoogleList.styles.ts b/frontend/src/components/SideGoogleList/SideGoogleList.styles.ts new file mode 100644 index 00000000..7b97ab4c --- /dev/null +++ b/frontend/src/components/SideGoogleList/SideGoogleList.styles.ts @@ -0,0 +1,83 @@ +import { css, Theme } from '@emotion/react'; + +const listStyle = ({ flex }: Theme, isSideBarOpen: boolean) => css` + ${flex.column} + + display: ${isSideBarOpen ? 'flex' : 'none'}; + justify-content: flex-start; + + width: 54rem; + + font-size: 4rem; +`; + +const headerLayoutStyle = ({ flex }: Theme) => css` + ${flex.row} + + justify-content: space-between; + + width: 100%; +`; + +const headerStyle = ({ flex }: Theme) => css` + ${flex.row} + + justify-content: space-between; + + width: 100%; + height: 8rem; + + font-weight: bold; + + cursor: pointer; +`; + +const contentStyle = (isListOpen: boolean, listLength: number) => css` + display: flex; + flex-direction: column; + gap: 2rem; + overflow: hidden; + + width: 100%; + height: ${isListOpen ? `${8 * (listLength + 1)}rem` : 0}; + + transition: height 0.3s ease-in-out; +`; + +const menuStyle = ({ colors }: Theme) => css` + position: relative; + + width: 9rem; + height: 9rem; + + &:hover { + border-radius: 50%; + + background: ${colors.GRAY_100}; + + filter: none; + } + + &:hover span { + visibility: visible; + } +`; + +const menuTitleStyle = ({ colors }: Theme) => css` + visibility: hidden; + position: absolute; + top: 120%; + left: 50%; + transform: translateX(-50%); + + padding: 2rem 3rem; + + background: ${colors.GRAY_700}ee; + + font-size: 3rem; + font-weight: normal; + color: ${colors.WHITE}; + white-space: nowrap; +`; + +export { contentStyle, headerLayoutStyle, headerStyle, listStyle, menuStyle, menuTitleStyle }; diff --git a/frontend/src/components/SideGoogleList/SideGoogleList.tsx b/frontend/src/components/SideGoogleList/SideGoogleList.tsx new file mode 100644 index 00000000..36c7a5d6 --- /dev/null +++ b/frontend/src/components/SideGoogleList/SideGoogleList.tsx @@ -0,0 +1,70 @@ +import { useTheme } from '@emotion/react'; +import { useRecoilValue } from 'recoil'; + +import useToggle from '@/hooks/useToggle'; + +import { SubscriptionType } from '@/@types/subscription'; + +import { sideBarState } from '@/recoil/atoms'; + +import Button from '@/components/@common/Button/Button'; +import ModalPortal from '@/components/@common/ModalPortal/ModalPortal'; +import GoogleImportModal from '@/components/GoogleImportModal/GoogleImportModal'; +import SideItem from '@/components/SideItem/SideItem'; + +import { AiOutlineDown, AiOutlineUp } from 'react-icons/ai'; +import { BsPlus } from 'react-icons/bs'; + +import { + contentStyle, + headerLayoutStyle, + headerStyle, + listStyle, + menuStyle, + menuTitleStyle, +} from './SideGoogleList.styles'; + +interface SideGoogleListProps { + categories: SubscriptionType[]; +} + +function SideGoogleList({ categories }: SideGoogleListProps) { + const isSideBarOpen = useRecoilValue(sideBarState); + + const theme = useTheme(); + + const { state: isGoogleListOpen, toggleState: toggleGoogleListOpen } = useToggle(true); + const { state: isGoogleImportModalOpen, toggleState: toggleGoogleImportModalOpen } = useToggle(); + + const handleClickGoogleImportButton = () => { + toggleGoogleImportModalOpen(); + }; + + return ( +
+
+ + 구글 카테고리 + + + +
+
+ {categories.map((el) => { + return ; + })} + {categories.length === 0 && 카테고리를 추가해주세요.} +
+ + + +
+ ); +} + +export default SideGoogleList; diff --git a/frontend/src/components/SideItem/SideItem.styles.ts b/frontend/src/components/SideItem/SideItem.styles.ts new file mode 100644 index 00000000..b4d78ec0 --- /dev/null +++ b/frontend/src/components/SideItem/SideItem.styles.ts @@ -0,0 +1,54 @@ +import { css, Theme } from '@emotion/react'; + +const itemStyle = ({ colors, flex }: Theme) => css` + ${flex.row}; + + justify-content: space-between; + + width: 100%; + height: 8rem; + + &:hover { + background-color: ${colors.GRAY_100}; + + button { + visibility: visible; + } + } +`; + +const checkBoxNameStyle = ({ flex }: Theme) => css` + ${flex.row}; + + gap: 1rem; + + &:hover { + cursor: pointer; + } +`; + +const nameStyle = css` + overflow: hidden; + position: relative; + + width: 32rem; + + white-space: nowrap; + text-overflow: ellipsis; +`; + +const headerStyle = css` + padding: 2rem; + + font-size: 5rem; +`; + +const iconStyle = css` + visibility: hidden; +`; + +const modalLayoutStyle = css` + position: relative; +`; + +export { itemStyle, checkBoxNameStyle, headerStyle, iconStyle, nameStyle, modalLayoutStyle }; diff --git a/frontend/src/components/FilterCategoryItem/FilterCategoryItem.tsx b/frontend/src/components/SideItem/SideItem.tsx similarity index 52% rename from frontend/src/components/FilterCategoryItem/FilterCategoryItem.tsx rename to frontend/src/components/SideItem/SideItem.tsx index 5f9eafcf..3068baaf 100644 --- a/frontend/src/components/FilterCategoryItem/FilterCategoryItem.tsx +++ b/frontend/src/components/SideItem/SideItem.tsx @@ -1,66 +1,63 @@ import { useMutation, useQueryClient } from 'react-query'; import { useRecoilValue } from 'recoil'; -import useToggle from '@/hooks/useToggle'; +import useModalPosition from '@/hooks/useModalPosition'; import { SubscriptionType } from '@/@types/subscription'; import { userState } from '@/recoil/atoms'; import Button from '@/components/@common/Button/Button'; +import ModalPortal from '@/components/@common/ModalPortal/ModalPortal'; import Spinner from '@/components/@common/Spinner/Spinner'; +import SubscriptionModifyModal from '@/components/SubscriptionModifyModal/SubscriptionModifyModal'; -import { CATEGORY_TYPE } from '@/constants/category'; -import { PALETTE } from '@/constants/style'; +import { TRANSPARENT } from '@/constants/style'; import subscriptionApi from '@/api/subscription'; -import { BiPalette } from 'react-icons/bi'; +import { BiDotsVerticalRounded } from 'react-icons/bi'; import { RiCheckboxBlankLine, RiCheckboxFill } from 'react-icons/ri'; import { checkBoxNameStyle, - colorStyle, - grayTextStyle, iconStyle, itemStyle, + modalLayoutStyle, nameStyle, - outerStyle, - paletteLayoutStyle, - paletteStyle, -} from './FilterCategoryItem.styles'; +} from './SideItem.styles'; -interface FilterItemProps { +interface SideItemProps { subscription: SubscriptionType; } -function FilterCategoryItem({ subscription }: FilterItemProps) { - const { accessToken } = useRecoilValue(userState); - - const { state: isPaletteOpen, toggleState: togglePaletteOpen } = useToggle(); +function SideItem({ subscription }: SideItemProps) { + const user = useRecoilValue(userState); const queryClient = useQueryClient(); - const { isLoading, mutate } = useMutation( + const { isLoading, mutate: patchSubscription } = useMutation( (body: Pick | Pick) => - subscriptionApi.patch(accessToken, subscription.id, body), + subscriptionApi.patch(user.accessToken, subscription.id, body), { onSuccess: () => queryClient.invalidateQueries(), } ); + const { + state: isPaletteOpen, + toggleState: togglePaletteOpen, + handleClickOpenButton, + modalPos, + } = useModalPosition(); + const handleClickCategoryItem = (checked: boolean, colorCode: string) => { - mutate({ + patchSubscription({ checked: !checked, colorCode, }); }; - const handleClickPalette = (checked: boolean, colorCode: string) => { - mutate({ checked, colorCode }); - togglePaletteOpen(); - }; - return (
@@ -90,38 +87,29 @@ function FilterCategoryItem({ subscription }: FilterItemProps) { }} > {subscription.category.name} - - {subscription.category.categoryType === CATEGORY_TYPE.GOOGLE && ' (구글)'} - {subscription.category.categoryType === CATEGORY_TYPE.PERSONAL && ' (기본)'} -
-
- - {isPaletteOpen && ( - <> -
-
- {PALETTE.map((color) => { - return ( - - ); - })} -
- + + + )}
); } -export default FilterCategoryItem; +export default SideItem; diff --git a/frontend/src/components/SideMyList/SideMyList.styles.ts b/frontend/src/components/SideMyList/SideMyList.styles.ts new file mode 100644 index 00000000..ee47a393 --- /dev/null +++ b/frontend/src/components/SideMyList/SideMyList.styles.ts @@ -0,0 +1,84 @@ +import { css, Theme } from '@emotion/react'; + +const listStyle = ({ flex }: Theme, isSideBarOpen: boolean) => css` + ${flex.column} + + display: ${isSideBarOpen ? 'flex' : 'none'}; + justify-content: flex-start; + + width: 54rem; + + font-size: 4rem; +`; + +const headerLayoutStyle = ({ flex }: Theme) => css` + ${flex.row} + + justify-content: space-between; + + width: 100%; +`; + +const headerStyle = ({ flex }: Theme) => css` + ${flex.row} + + justify-content: space-between; + + width: 100%; + height: 8rem; + + font-weight: bold; + + cursor: pointer; +`; + +const contentStyle = (isListOpen: boolean, listLength: number) => css` + display: flex; + flex-direction: column; + gap: 2rem; + overflow: hidden; + + width: 100%; + height: ${isListOpen ? `${8 * listLength}rem` : 0}; + margin-bottom: 5rem; + + transition: height 0.3s ease-in-out; +`; + +const menuStyle = ({ colors }: Theme) => css` + position: relative; + + width: 9rem; + height: 9rem; + + &:hover { + border-radius: 50%; + + background: ${colors.GRAY_100}; + + filter: none; + } + + &:hover span { + visibility: visible; + } +`; + +const menuTitleStyle = ({ colors }: Theme) => css` + visibility: hidden; + position: absolute; + top: 120%; + left: 50%; + transform: translateX(-50%); + + padding: 2rem 3rem; + + background: ${colors.GRAY_700}ee; + + font-size: 3rem; + font-weight: normal; + color: ${colors.WHITE}; + white-space: nowrap; +`; + +export { contentStyle, headerLayoutStyle, headerStyle, listStyle, menuStyle, menuTitleStyle }; diff --git a/frontend/src/components/SideMyList/SideMyList.tsx b/frontend/src/components/SideMyList/SideMyList.tsx new file mode 100644 index 00000000..75bd934c --- /dev/null +++ b/frontend/src/components/SideMyList/SideMyList.tsx @@ -0,0 +1,71 @@ +import { useTheme } from '@emotion/react'; +import { useRecoilValue } from 'recoil'; + +import useToggle from '@/hooks/useToggle'; + +import { SubscriptionType } from '@/@types/subscription'; + +import { sideBarState } from '@/recoil/atoms'; + +import Button from '@/components/@common/Button/Button'; +import ModalPortal from '@/components/@common/ModalPortal/ModalPortal'; +import CategoryAddModal from '@/components/CategoryAddModal/CategoryAddModal'; +import SideItem from '@/components/SideItem/SideItem'; + +import { AiOutlineDown, AiOutlineUp } from 'react-icons/ai'; +import { BsPlus } from 'react-icons/bs'; + +import { + contentStyle, + headerLayoutStyle, + headerStyle, + listStyle, + menuStyle, + menuTitleStyle, +} from './SideMyList.styles'; + +interface SideMyListProps { + categories: SubscriptionType[]; +} + +function SideMyList({ categories }: SideMyListProps) { + const isSideBarOpen = useRecoilValue(sideBarState); + + const theme = useTheme(); + + const { state: isMyListOpen, toggleState: toggleMyListOpen } = useToggle(true); + + const { state: isCategoryAddModalOpen, toggleState: toggleCategoryAddModalOpen } = useToggle(); + + const handleClickCategoryAddButton = () => { + toggleCategoryAddModalOpen(); + }; + + return ( +
+
+ + 나의 카테고리 + + + +
+ +
+ {categories.map((el) => { + return ; + })} +
+ + + +
+ ); +} + +export default SideMyList; diff --git a/frontend/src/components/SideSubscribedList/SideSubscribedList.styles.ts b/frontend/src/components/SideSubscribedList/SideSubscribedList.styles.ts new file mode 100644 index 00000000..56e4d94a --- /dev/null +++ b/frontend/src/components/SideSubscribedList/SideSubscribedList.styles.ts @@ -0,0 +1,83 @@ +import { css, Theme } from '@emotion/react'; + +const listStyle = ({ flex }: Theme, isSideBarOpen: boolean) => css` + ${flex.column} + + display: ${isSideBarOpen ? 'flex' : 'none'}; + justify-content: flex-start; + + width: 54rem; + + font-size: 4rem; +`; + +const headerLayoutStyle = ({ flex }: Theme) => css` + ${flex.row} + + justify-content: space-between; + + width: 100%; +`; + +const headerStyle = ({ flex }: Theme) => css` + ${flex.row} + + justify-content: space-between; + + width: 100%; + height: 8rem; + + font-weight: bold; + + cursor: pointer; +`; + +const contentStyle = (isListOpen: boolean, listLength: number) => css` + display: flex; + flex-direction: column; + gap: 2rem; + overflow: hidden; + + width: 100%; + height: ${isListOpen ? `${8 * (listLength + 1)}rem` : 0}; + margin-bottom: 5rem; + + transition: height 0.3s ease-in-out; +`; + +const menuStyle = ({ colors }: Theme) => css` + position: relative; + + width: 9rem; + height: 9rem; + + &:hover { + border-radius: 50%; + + background: ${colors.GRAY_100}; + + filter: none; + } + + &:hover span { + visibility: visible; + } +`; + +const menuTitleStyle = ({ colors }: Theme) => css` + visibility: hidden; + position: absolute; + top: 120%; + left: 50%; + transform: translateX(-50%); + + padding: 2rem 3rem; + + background: ${colors.GRAY_700}ee; + + font-size: 3rem; + font-weight: normal; + color: ${colors.WHITE}; + white-space: nowrap; +`; +export { contentStyle, headerLayoutStyle, headerStyle, listStyle, menuStyle, menuTitleStyle }; diff --git a/frontend/src/components/SideSubscribedList/SideSubscribedList.tsx b/frontend/src/components/SideSubscribedList/SideSubscribedList.tsx new file mode 100644 index 00000000..c379566e --- /dev/null +++ b/frontend/src/components/SideSubscribedList/SideSubscribedList.tsx @@ -0,0 +1,67 @@ +import { useTheme } from '@emotion/react'; +import { useNavigate } from 'react-router-dom'; +import { useRecoilValue } from 'recoil'; + +import useToggle from '@/hooks/useToggle'; + +import { SubscriptionType } from '@/@types/subscription'; + +import { sideBarState } from '@/recoil/atoms'; + +import Button from '@/components/@common/Button/Button'; +import SideItem from '@/components/SideItem/SideItem'; + +import { PATH } from '@/constants'; + +import { AiOutlineDown, AiOutlineUp } from 'react-icons/ai'; +import { BsPlus } from 'react-icons/bs'; + +import { + contentStyle, + headerLayoutStyle, + headerStyle, + listStyle, + menuStyle, + menuTitleStyle, +} from './SideSubscribedList.styles'; + +interface SideSubscribedListProps { + categories: SubscriptionType[]; +} + +function SideSubscribedList({ categories }: SideSubscribedListProps) { + const isSideBarOpen = useRecoilValue(sideBarState); + + const { state: isSubscribedListOpen, toggleState: toggleSubscribedListOpen } = useToggle(true); + + const theme = useTheme(); + + const navigate = useNavigate(); + + const handleClickCategoryAddButton = () => navigate(PATH.CATEGORY); + + return ( +
+
+ + 구독 카테고리 + + + +
+
+ {categories.map((el) => { + return ; + })} + {categories.length === 0 && 카테고리를 구독해주세요.} +
+
+ ); +} + +export default SideSubscribedList; diff --git a/frontend/src/components/SubscribedCategoryItem/SubscribedCategoryItem.tsx b/frontend/src/components/SubscribedCategoryItem/SubscribedCategoryItem.tsx index 1b678bab..25f1ff25 100644 --- a/frontend/src/components/SubscribedCategoryItem/SubscribedCategoryItem.tsx +++ b/frontend/src/components/SubscribedCategoryItem/SubscribedCategoryItem.tsx @@ -1,10 +1,10 @@ import { useTheme } from '@emotion/react'; -import { AxiosError, AxiosResponse } from 'axios'; -import { useMutation, useQuery, useQueryClient } from 'react-query'; +import { useMutation, useQueryClient } from 'react-query'; import { useRecoilValue } from 'recoil'; +import useUserValue from '@/hooks/useUserValue'; + import { CategoryType } from '@/@types/category'; -import { ProfileType } from '@/@types/profile'; import { userState } from '@/recoil/atoms'; @@ -15,7 +15,6 @@ import { CONFIRM_MESSAGE, TOOLTIP_MESSAGE } from '@/constants/message'; import { getISODateString } from '@/utils/date'; -import profileApi from '@/api/profile'; import subscriptionApi from '@/api/subscription'; import { categoryItem, item, menuTitle, unsubscribeButton } from './SubscribedCategoryItem.styles'; @@ -31,9 +30,7 @@ function SubscribedCategoryItem({ category, subscriptionId }: SubscribedCategory const queryClient = useQueryClient(); - const { data } = useQuery, AxiosError>(CACHE_KEY.PROFILE, () => - profileApi.get(accessToken) - ); + const { user } = useUserValue(); const { mutate } = useMutation(() => subscriptionApi.delete(accessToken, subscriptionId), { onSuccess: () => { @@ -47,7 +44,7 @@ function SubscribedCategoryItem({ category, subscriptionId }: SubscribedCategory } }; - const canUnsubscribeCategory = category.creator.id !== data?.data.id; + const canUnsubscribeCategory = category.creator.id !== user.id; return (
diff --git a/frontend/src/components/SubscriptionModifyModal/SubscriptionModifyModal.styles.ts b/frontend/src/components/SubscriptionModifyModal/SubscriptionModifyModal.styles.ts new file mode 100644 index 00000000..32bfec51 --- /dev/null +++ b/frontend/src/components/SubscriptionModifyModal/SubscriptionModifyModal.styles.ts @@ -0,0 +1,72 @@ +import { css, Theme } from '@emotion/react'; + +import { ModalPosType } from '@/@types'; + +const controlButtonStyle = ({ colors, flex }: Theme) => css` + ${flex.row}; + + justify-content: flex-start; + gap: 1rem; + + width: 100%; + padding: 2rem; + border-bottom: 1px solid ${colors.GRAY_300}; + box-sizing: contain; + + &:hover { + filter: none; + background-color: ${colors.GRAY_100}; + } +`; + +const colorStyle = (color: string) => css` + width: 5rem; + height: 5rem; + border-radius: 50%; + + background: ${color}; + + &:hover { + filter: none; + transform: scale(1.2); + } +`; + +const modalPosStyle = ({ colors, flex }: Theme, modalPos: ModalPosType) => css` + ${flex.column}; + + align-items: flex-start; + position: absolute; + top: ${modalPos.top ? `${modalPos.top}px` : 'none'}; + right: ${modalPos.right ? `${modalPos.right}px` : 'none'}; + bottom: ${modalPos.bottom ? `${modalPos.bottom}px` : 'none'}; + left: ${modalPos.left ? `${modalPos.left}px` : 'none'}; + + border: 1px solid ${colors.GRAY_300}; + border-radius: 4px; + + background: ${colors.WHITE}; +`; + +const paletteStyle = css` + display: grid; + grid-template-columns: repeat(4, 1fr); + place-items: center; + gap: 2rem; + + width: 35rem; + padding: 2rem; +`; + +const outerStyle = css` + position: fixed; + left: 0; + top: 16rem; + + width: 100%; + height: 100%; + + background-color: transparent; +`; + +export { colorStyle, controlButtonStyle, modalPosStyle, outerStyle, paletteStyle }; diff --git a/frontend/src/components/SubscriptionModifyModal/SubscriptionModifyModal.tsx b/frontend/src/components/SubscriptionModifyModal/SubscriptionModifyModal.tsx new file mode 100644 index 00000000..245ce8ed --- /dev/null +++ b/frontend/src/components/SubscriptionModifyModal/SubscriptionModifyModal.tsx @@ -0,0 +1,134 @@ +import { useTheme } from '@emotion/react'; +import { AxiosResponse } from 'axios'; +import { UseMutateFunction, useMutation, useQueryClient } from 'react-query'; +import { useRecoilValue } from 'recoil'; + +import useToggle from '@/hooks/useToggle'; + +import { ModalPosType } from '@/@types'; +import { SubscriptionType } from '@/@types/subscription'; + +import { userState } from '@/recoil/atoms'; + +import Button from '@/components/@common/Button/Button'; +import ModalPortal from '@/components/@common/ModalPortal/ModalPortal'; +import CategoryModifyModal from '@/components/CategoryModifyModal/CategoryModifyModal'; + +import { CACHE_KEY } from '@/constants/api'; +import { CATEGORY_TYPE } from '@/constants/category'; +import { CONFIRM_MESSAGE } from '@/constants/message'; +import { PALETTE } from '@/constants/style'; + +import categoryApi from '@/api/category'; + +import { FiEdit3 } from 'react-icons/fi'; +import { RiDeleteBin5Line } from 'react-icons/ri'; + +import { + colorStyle, + controlButtonStyle, + modalPosStyle, + outerStyle, + paletteStyle, +} from './SubscriptionModifyModal.styles'; + +interface SubscriptionModifyModalProps { + togglePaletteOpen: () => void; + modalPos: ModalPosType; + subscription: SubscriptionType; + patchSubscription: UseMutateFunction< + AxiosResponse, + unknown, + Pick | Pick, + unknown + >; +} + +function SubscriptionModifyModal({ + togglePaletteOpen, + modalPos, + subscription, + patchSubscription, +}: SubscriptionModifyModalProps) { + const theme = useTheme(); + + const user = useRecoilValue(userState); + + const queryClient = useQueryClient(); + + const { state: isCategoryModifyModalOpen, toggleState: toggleCategoryModifyModalOpen } = + useToggle(); + + const { mutate: deleteSubscription } = useMutation( + () => categoryApi.delete(user.accessToken, subscription.category.id), + { + onSuccess: () => onSuccessDeleteCategory(), + } + ); + + const onSuccessDeleteCategory = () => { + queryClient.invalidateQueries(CACHE_KEY.CATEGORIES); + queryClient.invalidateQueries(CACHE_KEY.MY_CATEGORIES); + queryClient.invalidateQueries(CACHE_KEY.SUBSCRIPTIONS); + }; + + const handleClickDeleteButton = () => { + if (confirm(CONFIRM_MESSAGE.DELETE)) { + deleteSubscription(); + } + }; + + const handleClickPalette = (checked: boolean, colorCode: string) => { + patchSubscription({ checked, colorCode }); + togglePaletteOpen(); + }; + + const canEditSubscription = + subscription.category.categoryType !== CATEGORY_TYPE.PERSONAL && + subscription.category.creator.id === user.id; + + return ( + <> +
+
+ {canEditSubscription && ( + <> + + + + )} + +
+ {PALETTE.map((color) => { + return ( + + ); + })} +
+
+ + { + togglePaletteOpen(); + toggleCategoryModifyModalOpen(); + }} + /> + + + ); +} + +export default SubscriptionModifyModal; diff --git a/frontend/src/components/WithdrawalModal/WithdrawalModal.stories.tsx b/frontend/src/components/WithdrawalModal/WithdrawalModal.stories.tsx new file mode 100644 index 00000000..b16155a3 --- /dev/null +++ b/frontend/src/components/WithdrawalModal/WithdrawalModal.stories.tsx @@ -0,0 +1,12 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; + +import WithdrawalModal from './WithdrawalModal'; + +export default { + title: 'Components/WithdrawalModal', + component: WithdrawalModal, +} as ComponentMeta; + +const Template: ComponentStory = (args) => ; + +export const Primary = Template.bind({}); diff --git a/frontend/src/components/WithdrawalModal/WithdrawalModal.styles.ts b/frontend/src/components/WithdrawalModal/WithdrawalModal.styles.ts new file mode 100644 index 00000000..8c1fcb9f --- /dev/null +++ b/frontend/src/components/WithdrawalModal/WithdrawalModal.styles.ts @@ -0,0 +1,46 @@ +import { css, Theme } from '@emotion/react'; + +const headerStyle = css` + width: 100%; + + padding: 0 auto; + + font-size: 8rem; + font-weight: bold; + text-align: center; +`; + +const layoutStyle = ({ colors, flex }: Theme) => css` + ${flex.column}; + align-items: flex-start; + + gap: 6rem; + + width: 100rem; + padding: 12.5rem; + border-radius: 12px; + + font-size: 4rem; + line-height: 6rem; + + background: ${colors.WHITE}; +`; + +const withdrawalButtonStyle = ({ colors }: Theme) => css` + width: 80%; + margin: 0 auto; + + padding: 2rem 3rem; + border: 1px solid ${colors.RED_400}; + border-radius: 3px; + + font-size: 3rem; + color: ${colors.RED_400}; +`; + +const withdrawalConditionTextStyle = ({ colors }: Theme) => css` + color: ${colors.RED_400}; + font-weight: 700; +`; + +export { headerStyle, layoutStyle, withdrawalButtonStyle, withdrawalConditionTextStyle }; diff --git a/frontend/src/components/WithdrawalModal/WithdrawalModal.tsx b/frontend/src/components/WithdrawalModal/WithdrawalModal.tsx new file mode 100644 index 00000000..2e7ddb07 --- /dev/null +++ b/frontend/src/components/WithdrawalModal/WithdrawalModal.tsx @@ -0,0 +1,82 @@ +import { validateWithdrawalCondition } from '@/validation'; +import { useMutation } from 'react-query'; +import { useNavigate } from 'react-router-dom'; +import { useRecoilValue } from 'recoil'; + +import useControlledInput from '@/hooks/useControlledInput'; + +import { userState } from '@/recoil/atoms'; + +import Button from '@/components/@common/Button/Button'; +import Fieldset from '@/components/@common/Fieldset/Fieldset'; + +import { PATH } from '@/constants'; +import { CONFIRM_MESSAGE } from '@/constants/message'; +import { VALIDATION_STRING } from '@/constants/validate'; + +import { removeAccessToken } from '@/utils/storage'; + +import profileApi from '@/api/profile'; + +import { + headerStyle, + layoutStyle, + withdrawalButtonStyle, + withdrawalConditionTextStyle, +} from './WithdrawalModal.styles'; + +interface WithdrawalModalProps { + closeModal: () => void; +} + +function WithdrawalModal({ closeModal }: WithdrawalModalProps) { + const navigate = useNavigate(); + + const { accessToken } = useRecoilValue(userState); + + const { inputValue, onChangeValue } = useControlledInput(); + + const { mutate } = useMutation(() => profileApi.delete(accessToken), { + onSuccess: () => onSuccessWithdrawalUser(), + }); + + const handleClickWithdrawalButton = () => { + if (window.confirm(CONFIRM_MESSAGE.WITHDRAWAL)) { + mutate(); + } + }; + + const onSuccessWithdrawalUser = () => { + closeModal(); + removeAccessToken(); + navigate(PATH.MAIN); + location.reload(); + }; + + return ( +
+

달록 탈퇴

+

탈퇴를 진행하면 일정과 카테고리를 비롯한 모든 정보가 영구적으로 삭제됩니다.

+

그래도 탈퇴하시겠습니까?

+

+ 탈퇴를 원하시면{' '} + {VALIDATION_STRING.WITHDRAWAL}를 + 입력해주세요. +

+
+ +
+ ); +} + +export default WithdrawalModal; diff --git a/frontend/src/constants/message.ts b/frontend/src/constants/message.ts index e4c4bbfb..c5f73894 100644 --- a/frontend/src/constants/message.ts +++ b/frontend/src/constants/message.ts @@ -2,6 +2,7 @@ const CONFIRM_MESSAGE = { DELETE: '정말 삭제하시겠습니까?', UNSUBSCRIBE: '구독을 해제하시겠습니까?', LOGOUT: '로그아웃하시겠습니까?', + WITHDRAWAL: '정말 탈퇴하시겠습니까?', }; const ERROR_MESSAGE = { diff --git a/frontend/src/constants/validate.ts b/frontend/src/constants/validate.ts index 3a2c98f4..89eb97e4 100644 --- a/frontend/src/constants/validate.ts +++ b/frontend/src/constants/validate.ts @@ -7,6 +7,7 @@ const VALIDATION_SIZE = { const VALIDATION_STRING = { CATEGORY: '내 일정', + WITHDRAWAL: '달록 탈퇴', }; const VALIDATION_MESSAGE = { diff --git a/frontend/src/hooks/useModalPosition.ts b/frontend/src/hooks/useModalPosition.ts new file mode 100644 index 00000000..71d56560 --- /dev/null +++ b/frontend/src/hooks/useModalPosition.ts @@ -0,0 +1,40 @@ +import { useState } from 'react'; + +import { ModalPosType } from '@/@types'; + +import useToggle from './useToggle'; + +function useModalPosition() { + const [modalPos, setModalPos] = useState({}); + + const { state, toggleState } = useToggle(); + + const handleClickOpenButton = (e: React.MouseEvent) => { + if (e.target !== e.currentTarget) { + return; + } + + setModalPos(calculateModalPos(e.clientX, e.clientY)); + toggleState(); + }; + + const calculateModalPos = (clickX: number, clickY: number) => { + const position = { top: clickY, right: 0, bottom: 0, left: clickX }; + + if (clickX > innerWidth / 2) { + position.right = innerWidth - clickX; + position.left = 0; + } + + if (clickY > innerHeight / 2) { + position.bottom = innerHeight - clickY; + position.top = 0; + } + + return position; + }; + + return { state, toggleState, handleClickOpenButton, modalPos }; +} + +export default useModalPosition; diff --git a/frontend/src/hooks/useUserValue.ts b/frontend/src/hooks/useUserValue.ts index 92a68ee6..a24c04f6 100644 --- a/frontend/src/hooks/useUserValue.ts +++ b/frontend/src/hooks/useUserValue.ts @@ -1,6 +1,8 @@ import { AxiosError, AxiosResponse } from 'axios'; import { useQuery } from 'react-query'; -import { useRecoilValue, useResetRecoilState, useSetRecoilState } from 'recoil'; +import { useRecoilState, useResetRecoilState, useSetRecoilState } from 'recoil'; + +import { ProfileType } from '@/@types/profile'; import { sideBarState, userState } from '@/recoil/atoms'; @@ -9,14 +11,15 @@ import { CACHE_KEY } from '@/constants/api'; import { removeAccessToken } from '@/utils/storage'; import loginApi from '@/api/login'; +import profileApi from '@/api/profile'; function useUserValue() { - const user = useRecoilValue(userState); + const [user, setUser] = useRecoilState(userState); const resetUser = useResetRecoilState(userState); const setSideBarOpen = useSetRecoilState(sideBarState); - const { isLoading } = useQuery( + const { isLoading, isSuccess } = useQuery( CACHE_KEY.VALIDATE, () => loginApi.validate(user.accessToken), { @@ -27,6 +30,19 @@ function useUserValue() { } ); + useQuery, AxiosError>( + CACHE_KEY.PROFILE, + () => profileApi.get(user.accessToken), + { + onError: () => onErrorValidate(), + onSuccess: ({ data }) => setUser({ ...user, ...data }), + retry: false, + useErrorBoundary: false, + enabled: isSuccess, + staleTime: 5 * 60 * 1000, + } + ); + const onErrorValidate = () => { setSideBarOpen(false); removeAccessToken(); diff --git a/frontend/src/pages/CategoryPage/CategoryPage.styles.ts b/frontend/src/pages/CategoryPage/CategoryPage.styles.ts index 947c60de..e799f6b3 100644 --- a/frontend/src/pages/CategoryPage/CategoryPage.styles.ts +++ b/frontend/src/pages/CategoryPage/CategoryPage.styles.ts @@ -5,6 +5,16 @@ const categoryPageStyle = css` padding: 9rem; `; +const controlStyle = ({ flex }: Theme) => css` + ${flex.row}; + + align-items: flex-start; + justify-content: center; + gap: 4rem; + + width: 100%; +`; + const searchFormStyle = css` position: relative; @@ -51,62 +61,12 @@ const buttonStyle = ({ colors }: Theme) => css` } `; -const controlStyle = ({ flex }: Theme) => css` - ${flex.row}; - - align-items: flex-start; - justify-content: center; - gap: 4rem; - - width: 100%; -`; - -const outLineButtonStyle = ({ colors }: Theme) => css` - width: 40rem; - height: 12rem; - border-radius: 8px; - border: 1px solid ${colors.GRAY_500}; - - font-size: 3.5rem; - font-weight: 700; - line-height: 3.5rem; - color: ${colors.YELLOW_500}; -`; - -const toggleModeStyle = ({ colors, flex }: Theme, mode: 'ALL' | 'MY') => css` - ${flex.row}; - - justify-content: space-around; - - width: 35rem; - height: 12rem; - padding: 0 1rem; - border-radius: 8px; - border: 1px solid ${colors.GRAY_500}; - - background: linear-gradient( - 90deg, - ${mode === 'ALL' ? colors.YELLOW_500 : colors.WHITE} 50%, - ${mode === 'MY' ? colors.YELLOW_500 : colors.WHITE} 50% - ); -`; - -const modeTextStyle = ({ colors }: Theme, isSelected: boolean) => css` - font-size: 3.5rem; - font-weight: 700; - line-height: 3.5rem; - color: ${isSelected ? colors.WHITE : colors.YELLOW_500}; -`; - export { buttonStyle, categoryPageStyle, controlStyle, - modeTextStyle, - outLineButtonStyle, searchButtonStyle, searchFieldsetStyle, searchFormStyle, searchInputStyle, - toggleModeStyle, }; diff --git a/frontend/src/pages/CategoryPage/CategoryPage.tsx b/frontend/src/pages/CategoryPage/CategoryPage.tsx index ec8cdd3d..214099a7 100644 --- a/frontend/src/pages/CategoryPage/CategoryPage.tsx +++ b/frontend/src/pages/CategoryPage/CategoryPage.tsx @@ -9,7 +9,6 @@ import ModalPortal from '@/components/@common/ModalPortal/ModalPortal'; import PageLayout from '@/components/@common/PageLayout/PageLayout'; import CategoryAddModal from '@/components/CategoryAddModal/CategoryAddModal'; import CategoryListFallback from '@/components/CategoryList/CategoryList.fallback'; -import MyCategoryListFallback from '@/components/MyCategoryList/MyCategoryList.fallback'; import { GoSearch } from 'react-icons/go'; @@ -17,20 +16,16 @@ import { buttonStyle, categoryPageStyle, controlStyle, - modeTextStyle, searchButtonStyle, searchFieldsetStyle, searchFormStyle, searchInputStyle, - toggleModeStyle, } from './CategoryPage.styles'; const CategoryList = lazy(() => import('@/components/CategoryList/CategoryList')); -const MyCategoryList = lazy(() => import('@/components/MyCategoryList/MyCategoryList')); function CategoryPage() { const theme = useTheme(); - const [mode, setMode] = useState<'ALL' | 'MY'>('ALL'); const { state: isCategoryAddModalOpen, toggleState: toggleCategoryAddModalOpen } = useToggle(); const keywordRef = useRef(null); @@ -47,11 +42,6 @@ function CategoryPage() { setKeyword((keywordRef.current as HTMLInputElement).value); }; - const handleClickFilteringButton = () => { - mode === 'ALL' && setMode('MY'); - mode === 'MY' && setMode('ALL'); - }; - const handleClickCategoryAddButton = () => { toggleCategoryAddModalOpen(); }; @@ -68,30 +58,18 @@ function CategoryPage() {
-
- {mode === 'ALL' && ( - }> - - - )} - {mode === 'MY' && ( - }> - - - )} + }> + +
); diff --git a/frontend/src/recoil/atoms/index.ts b/frontend/src/recoil/atoms/index.ts index 2dc19022..6ca64c6d 100644 --- a/frontend/src/recoil/atoms/index.ts +++ b/frontend/src/recoil/atoms/index.ts @@ -1,15 +1,21 @@ import { atom } from 'recoil'; +import { ProfileType } from '@/@types/profile'; + import { ATOM_KEY } from '@/constants'; import { getAccessToken } from '@/utils/storage'; +interface UserStateType extends Partial { + accessToken: string; +} + const sideBarState = atom({ key: ATOM_KEY.SIDE_BAR, default: false, }); -const userState = atom({ +const userState = atom({ key: ATOM_KEY.USER, default: { accessToken: getAccessToken() ?? '', diff --git a/frontend/src/validation/index.ts b/frontend/src/validation/index.ts index a4fe9a0f..59966f5a 100644 --- a/frontend/src/validation/index.ts +++ b/frontend/src/validation/index.ts @@ -1,17 +1,21 @@ -const validateLength = (target: string, min: number, max: number) => { - return min <= target.length && target.length <= max; -}; +import { VALIDATION_STRING } from '@/constants/validate'; -const validateNotEmpty = (target: string) => { - return target.length > 0; -}; +const validateLength = (target: string, min: number, max: number) => + min <= target.length && target.length <= max; -const validateNotEqualString = (target: string, comparisonTarget: string) => { - return target.trim() !== comparisonTarget; -}; +const validateNotEmpty = (target: string) => target.length > 0; -const validateStartEndDateTime = (startDate: string, endDate: string) => { - return startDate <= endDate; -}; +const validateNotEqualString = (target: string, comparisonTarget: string) => + target.trim() !== comparisonTarget; -export { validateLength, validateNotEmpty, validateNotEqualString, validateStartEndDateTime }; +const validateStartEndDateTime = (startDate: string, endDate: string) => startDate <= endDate; + +const validateWithdrawalCondition = (value: string) => value === VALIDATION_STRING.WITHDRAWAL; + +export { + validateLength, + validateNotEmpty, + validateNotEqualString, + validateStartEndDateTime, + validateWithdrawalCondition, +};