From 167c4366de10755f5e33c08614901a78de239a15 Mon Sep 17 00:00:00 2001 From: rondido Date: Mon, 19 Feb 2024 22:47:14 +0900 Subject: [PATCH 01/12] =?UTF-8?q?=20feature:=20header=20=EA=B2=80=EC=83=89?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stories/ChatBubble/ChatBubble.stories.ts | 22 ++++++++ .../src/stories/ChatBubble/ChatBubble.tsx | 55 +++++++++++++++++++ component/src/stories/header/Header.tsx | 18 +++++- service/src/App.tsx | 1 + .../src/recoil/atom/isSearchClickedState.ts | 6 ++ 5 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 component/src/stories/ChatBubble/ChatBubble.stories.ts create mode 100644 component/src/stories/ChatBubble/ChatBubble.tsx create mode 100644 service/src/recoil/atom/isSearchClickedState.ts diff --git a/component/src/stories/ChatBubble/ChatBubble.stories.ts b/component/src/stories/ChatBubble/ChatBubble.stories.ts new file mode 100644 index 00000000..a9a67284 --- /dev/null +++ b/component/src/stories/ChatBubble/ChatBubble.stories.ts @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import chatBubble from './ChatBubble'; + +// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction#default-export +const meta = { + title: 'Example/chatBubble', + component: chatBubble, + parameters: { + // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/react/configure/story-layout + layout: 'centered', + }, + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/react/writing-docs/autodocs + tags: ['autodocs'], + // More on argTypes: https://storybook.js.org/docs/react/api/argtypes +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args +export const ChatBubble: Story = {}; diff --git a/component/src/stories/ChatBubble/ChatBubble.tsx b/component/src/stories/ChatBubble/ChatBubble.tsx new file mode 100644 index 00000000..434eda19 --- /dev/null +++ b/component/src/stories/ChatBubble/ChatBubble.tsx @@ -0,0 +1,55 @@ +import LanguageIcon from '@mui/icons-material/Language'; +import CallMadeIcon from '@mui/icons-material/CallMade'; +import { isSearchClickedState } from '@monorepo/service/src/recoil/atom/isSearchClickedState'; +import { useSetRecoilState } from 'recoil'; +import { useEffect, useRef } from 'react'; +import { Link } from 'react-router-dom'; +export default function ChatBubble() { + const setIsSearchClicked = useSetRecoilState(isSearchClickedState); + const searchBoxRef = useRef(null); + + useEffect(() => { + // 검색 상자가 표시되면 외부 클릭을 감지하여 상자를 숨깁니다. + const handleClickOutside = (event: MouseEvent) => { + if ( + searchBoxRef.current && + !searchBoxRef.current.contains(event.target as HTMLElement) + ) { + setIsSearchClicked(false); + } + }; + + // 페이지가 로드될 때 이벤트 리스너를 추가합니다. + document.addEventListener('mousedown', handleClickOutside); + + // 컴포넌트가 언마운트될 때 이벤트 리스너를 제거합니다. + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + return ( +
+
+
+
+ +

+ + Explore topics +

+
+ +
+ +
+
+ +
+
+
+ ); +} diff --git a/component/src/stories/header/Header.tsx b/component/src/stories/header/Header.tsx index c60f487f..80c95da9 100644 --- a/component/src/stories/header/Header.tsx +++ b/component/src/stories/header/Header.tsx @@ -12,6 +12,8 @@ import { } from '@monorepo/service/src/repository/ProfileimgRepository'; import { useRecoilState, useSetRecoilState } from 'recoil'; import { profileModalOpen } from '@monorepo/service/src/recoil/atom/profileModalOpen'; +import ChatBubble from '../ChatBubble/ChatBubble'; +import { isSearchClickedState } from '@monorepo/service/src/recoil/atom/isSearchClickedState'; const InputTextField = styled(TextField)({ '& label': { @@ -47,6 +49,8 @@ interface IHandleProps { function AuthHeader({ onLogout }: IHandleProps) { const [profileOpen, setProfileOpen] = useRecoilState(profileModalOpen); + const setIsSearchClicked = useSetRecoilState(isSearchClickedState); + const KEY = 'imgUrl'; const img = getProfileImgStorage(KEY); let imgUrl; @@ -58,6 +62,7 @@ function AuthHeader({ onLogout }: IHandleProps) { //위 코드가 없을 경우 header 영역 밖을 클릭할 경우의 코드를 넣으면 profilediv 영역이 나타나지 않는다. e.stopPropagation(); setProfileOpen(!profileOpen); + setIsSearchClicked(false); }; return ( @@ -89,6 +94,9 @@ interface IHeaderProps { } export default function Header({ authToken }: IHeaderProps) { + const [isSearchClicked, setIsSearchClicked] = useRecoilState(isSearchClickedState); + //const [searchValue, setSearchValue] = useState(''); + const { darkMode, toggleDarkMode } = useDarkMode(); const setProfileOpen = useSetRecoilState(profileModalOpen); const handleModalClose = () => { @@ -101,6 +109,11 @@ export default function Header({ authToken }: IHeaderProps) { setProfileOpen(false); // 프로필 메뉴 닫기 }; + const handleSearchValue = (e: React.ChangeEvent) => { + const value = e.target as HTMLElement; + console.log(value); + }; + return (
-
+
setIsSearchClicked(!isSearchClicked)} + onChange={e => handleSearchValue(e)} /> + {isSearchClicked && }
diff --git a/service/src/App.tsx b/service/src/App.tsx index 79a48d36..5963d239 100644 --- a/service/src/App.tsx +++ b/service/src/App.tsx @@ -40,6 +40,7 @@ function App() { } /> } /> } /> + } /> } /> } /> diff --git a/service/src/recoil/atom/isSearchClickedState.ts b/service/src/recoil/atom/isSearchClickedState.ts new file mode 100644 index 00000000..c73eb4f2 --- /dev/null +++ b/service/src/recoil/atom/isSearchClickedState.ts @@ -0,0 +1,6 @@ +import { atom } from 'recoil'; + +export const isSearchClickedState = atom({ + key: 'isSearchClickedState', + default: false, +}); From 37188a3e234b658542814bc3cd0de73ff3454a39 Mon Sep 17 00:00:00 2001 From: rondido Date: Wed, 21 Feb 2024 22:47:44 +0900 Subject: [PATCH 02/12] =?UTF-8?q?feature:=20header=20=EA=B2=80=EC=83=89?= =?UTF-8?q?=EC=B0=BD=20=EA=B5=AC=ED=98=84=20=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/stories/ChatBubble/ChatBubble.tsx | 13 +++- component/src/stories/header/Header.tsx | 62 ++++++++++++++--- component/src/stories/listbox/Listbox.tsx | 8 +-- component/src/stories/listbox/type.ts | 6 +- service/src/App.tsx | 4 +- service/src/components/card/CardList.tsx | 2 +- .../src/components/carousel/CategorySlide.tsx | 8 +-- .../src/components/modal/CategoryModal.tsx | 3 - service/src/pages/HeaderSearchPage.tsx | 68 +++++++++++++++++++ service/src/pages/MainPage.tsx | 6 +- service/src/recoil/atom/DisplayModeState.ts | 6 ++ .../src/recoil/atom/HeaderSearchDataState.ts | 20 ++++++ service/src/recoil/atom/PageState.ts | 6 ++ service/src/service/HeaderSearchService.ts | 20 ++++++ 14 files changed, 198 insertions(+), 34 deletions(-) create mode 100644 service/src/pages/HeaderSearchPage.tsx create mode 100644 service/src/recoil/atom/DisplayModeState.ts create mode 100644 service/src/recoil/atom/HeaderSearchDataState.ts create mode 100644 service/src/recoil/atom/PageState.ts create mode 100644 service/src/service/HeaderSearchService.ts diff --git a/component/src/stories/ChatBubble/ChatBubble.tsx b/component/src/stories/ChatBubble/ChatBubble.tsx index 434eda19..1faac396 100644 --- a/component/src/stories/ChatBubble/ChatBubble.tsx +++ b/component/src/stories/ChatBubble/ChatBubble.tsx @@ -4,7 +4,9 @@ import { isSearchClickedState } from '@monorepo/service/src/recoil/atom/isSearch import { useSetRecoilState } from 'recoil'; import { useEffect, useRef } from 'react'; import { Link } from 'react-router-dom'; -export default function ChatBubble() { + +export default function ChatBubble({ onSearchValue }: any) { + console.log(onSearchValue); const setIsSearchClicked = useSetRecoilState(isSearchClickedState); const searchBoxRef = useRef(null); @@ -32,7 +34,14 @@ export default function ChatBubble() {
-
+
+
+ {onSearchValue.length !== 1 && + onSearchValue?.map((value: string, index: number) => ( +

{value}

+ ))} +
+
([]); + const setTechBlogSearchData = useSetRecoilState(HeaderSearchDataState); const { darkMode, toggleDarkMode } = useDarkMode(); + const page = useRecoilValue(PageState); + const setProfileOpen = useSetRecoilState(profileModalOpen); + const navigate = useNavigate(); + const size = 10; + const sort = 'writtenAt'; + const direction = 'desc'; + async function getKeywordSerchRender() { + const keywordSearchData = await getHeaderKeywordSearch({ + page, + size, + sort, + direction, + searchValue, + }); + setTechBlogSearchData(prev => [...prev, ...keywordSearchData.content]); + } + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + // 엔터 키를 눌렀을 때 실행할 동작 + setTechBlogSearchData([]); + getKeywordSerchRender(); + setValueRest(true); + setIsSearchClicked(false); + setResultSearchValue(prev => [...prev, ...resultSearchValue]); + navigate('/search', { state: searchValue }); + } + }; + const handleModalClose = () => { setProfileOpen(false); }; @@ -109,19 +144,25 @@ export default function Header({ authToken }: IHeaderProps) { setProfileOpen(false); // 프로필 메뉴 닫기 }; - const handleSearchValue = (e: React.ChangeEvent) => { - const value = e.target as HTMLElement; - console.log(value); + const handleSearchValue = (e: React.ChangeEvent) => { + const value = e.target.value; + setSearchValue(value); }; + useEffect(() => { + getKeywordSerchRender(); + }, [page]); + return (
-
- +
+ + +
setIsSearchClicked(!isSearchClicked)} + onKeyPress={e => handleKeyPress(e)} onChange={e => handleSearchValue(e)} + autoComplete="off" + value={!valueReset ? searchValue : ''} /> - {isSearchClicked && } + {isSearchClicked && }
diff --git a/component/src/stories/listbox/Listbox.tsx b/component/src/stories/listbox/Listbox.tsx index 4d7f3513..f13b2e7a 100644 --- a/component/src/stories/listbox/Listbox.tsx +++ b/component/src/stories/listbox/Listbox.tsx @@ -4,17 +4,15 @@ import { IListBoxProps } from './type'; export default function ListBox({ items, onCategoryId, onFilterItems }: IListBoxProps) { return ( <> - {onCategoryId === 0 ? ( -
+ {onCategoryId === 0 || onCategoryId === undefined ? ( +
{items.map((item: any) => ( ))}
) : (
- {onFilterItems.map((item: any) => ( - - ))} + {onFilterItems?.map((item: any) => )}
)} diff --git a/component/src/stories/listbox/type.ts b/component/src/stories/listbox/type.ts index d1daa220..9a2799d6 100644 --- a/component/src/stories/listbox/type.ts +++ b/component/src/stories/listbox/type.ts @@ -1,9 +1,9 @@ export interface IListBoxProps { items: any; - onCategoryId: number; - onFilterItems: any; + onCategoryId?: number; + onFilterItems?: any; } export interface ItemProps { - item: any; + item?: any; } diff --git a/service/src/App.tsx b/service/src/App.tsx index 5963d239..358bc762 100644 --- a/service/src/App.tsx +++ b/service/src/App.tsx @@ -9,6 +9,7 @@ import { ThemeProvider } from '@mui/material'; import { darkModeState } from './recoil/atom/darkModeState'; import { useRecoilValue } from 'recoil'; import { createTheme } from '@mui/material/styles'; +import HeaderSearchPage from './pages/HeaderSearchPage'; function App() { const darkMode = useRecoilValue(darkModeState); @@ -31,7 +32,7 @@ function App() { }, }, }); - console.log(darkMode); + return ( @@ -41,6 +42,7 @@ function App() { } /> } /> } /> + } /> } /> } /> diff --git a/service/src/components/card/CardList.tsx b/service/src/components/card/CardList.tsx index a77140ea..ad0a107d 100644 --- a/service/src/components/card/CardList.tsx +++ b/service/src/components/card/CardList.tsx @@ -4,7 +4,7 @@ import { IListBoxProps } from '@monorepo/component/src/stories/listbox/type'; export default function CardList({ items, onCategoryId, onFilterItems }: IListBoxProps) { return ( <> - {onCategoryId === 0 ? ( + {onCategoryId === 0 || onCategoryId === undefined ? (
{items.map((item: any) => ( diff --git a/service/src/components/carousel/CategorySlide.tsx b/service/src/components/carousel/CategorySlide.tsx index efc1d959..c186640b 100644 --- a/service/src/components/carousel/CategorySlide.tsx +++ b/service/src/components/carousel/CategorySlide.tsx @@ -129,13 +129,7 @@ export default function CategorySlide({
- - {/*
*/} + { + setPage(prev => prev + 1); + }, [techBlogSearchData]); + + const setObservationTarget = useIntersectionObserver(fetchMoreIssue); + + return ( +
+
+
+
+

Result Value:{location.state}

+
+ + setDisplayMode(e.target.checked)} + aria-label="DisplayMode Switch" + /> + +
+
+ {techBlogSearchData.length !== 0 ? ( + displayMode ? ( + + ) : ( + + ) + ) : ( +
+ +

검색된 결과가 없습니다.

+
+ )} +
+
+
+
+ ); +} diff --git a/service/src/pages/MainPage.tsx b/service/src/pages/MainPage.tsx index 2e935c17..654bf675 100644 --- a/service/src/pages/MainPage.tsx +++ b/service/src/pages/MainPage.tsx @@ -7,24 +7,24 @@ import ListBox from '@monorepo/component/src/stories/listbox/Listbox'; import SignUpModal from '../components/signup/SignUpModal'; import CardList from '../components/card/CardList'; import { profileModalOpen } from '../recoil/atom/profileModalOpen'; - import { getAuthStorage } from '../repository/AuthRepository'; - import CategorySlide from '../components/carousel/CategorySlide'; import { userCategoryState } from '../recoil/atom/userCategoryState'; import { useTokenDecode } from '../hooks/useTokenDecode'; import { AuthCategoryService } from '../service/CategoryService'; import { getTechBlogService, getUserTechBlogService } from '../service/TechBlogService'; import { useIntersectionObserver } from '../hooks/useIntersectionObserver'; +import { DisplayModeState } from '../recoil/atom/DisplayModeState'; export default function MainPage() { const [isCategoryModalOpen, setCategoryModalOpen] = useState(false); const [techBlogData, setTechBlogData] = useState([]); const [filterTechBlogData, setFilterTechBlogData] = useState([]); - const [displayMode, setDisplayMode] = useState(true); + const [displayMode, setDisplayMode] = useRecoilState(DisplayModeState); const [page, setPage] = useState(0); const [categoryId, setCategoryId] = useState(0); const [userCategoryItems, setUserCategoryItems] = useRecoilState(userCategoryState); //선호 카테고리 + const size = 10; const sort = 'createdAt'; diff --git a/service/src/recoil/atom/DisplayModeState.ts b/service/src/recoil/atom/DisplayModeState.ts new file mode 100644 index 00000000..be1d6058 --- /dev/null +++ b/service/src/recoil/atom/DisplayModeState.ts @@ -0,0 +1,6 @@ +import { atom } from 'recoil'; + +export const DisplayModeState = atom({ + key: 'DisplayModeState', + default: true, +}); diff --git a/service/src/recoil/atom/HeaderSearchDataState.ts b/service/src/recoil/atom/HeaderSearchDataState.ts new file mode 100644 index 00000000..b58b0051 --- /dev/null +++ b/service/src/recoil/atom/HeaderSearchDataState.ts @@ -0,0 +1,20 @@ +import { atom } from 'recoil'; + +interface Props { + techBlogPostBasicInfoDto: { + id: number; + title: string; + summary: string; + techBlogCode: string; + thumbnailUrl: string; + viewCount: number; + postLike: number; + writtenAt: Date; + url: string; + }; +} + +export const HeaderSearchDataState = atom({ + key: 'HeaderSearchDataState', + default: [], +}); diff --git a/service/src/recoil/atom/PageState.ts b/service/src/recoil/atom/PageState.ts new file mode 100644 index 00000000..3e48dbb5 --- /dev/null +++ b/service/src/recoil/atom/PageState.ts @@ -0,0 +1,6 @@ +import { atom } from 'recoil'; + +export const PageState = atom({ + key: 'PageState', + default: 0, +}); diff --git a/service/src/service/HeaderSearchService.ts b/service/src/service/HeaderSearchService.ts new file mode 100644 index 00000000..3122e1e4 --- /dev/null +++ b/service/src/service/HeaderSearchService.ts @@ -0,0 +1,20 @@ +import HttpClient from '../apis/HttpClient'; + +interface Props { + page: number; + size: number; + sort: string; + direction: string; + searchValue: string; +} + +export async function getHeaderKeywordSearch({ page, size, sort, direction, searchValue }: Props) { + try { + const res = await HttpClient.get( + `api/v1/posts/title/keyword-search?page=${page}&size=${size}&sort=${sort}&direction=${direction}&keyword=${searchValue}`, + ); + return res.data; + } catch (error) { + console.error(error); + } +} From 289c56c9858625b2e91382404dd75874bd68ad38 Mon Sep 17 00:00:00 2001 From: rondido Date: Mon, 26 Feb 2024 23:00:44 +0900 Subject: [PATCH 03/12] =?UTF-8?q?=20feature:=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=EA=B0=80=20=EA=B2=80=EC=83=89=ED=95=9C=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EB=82=B4=EC=9A=A9=20header=EC=97=90=20=EB=B3=B4=EC=97=AC?= =?UTF-8?q?=EC=A3=BC=EA=B8=B0(localstorage=EC=9D=98=20=EC=A0=80=EC=9E=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/stories/ChatBubble/ChatBubble.tsx | 40 +++++++++++++------ component/src/stories/header/Header.tsx | 27 ++++++++----- service/src/App.tsx | 2 +- .../src/components/modal/CategoryModal.tsx | 2 +- service/src/pages/HeaderSearchPage.tsx | 20 +++++++--- .../src/repository/SearchListRepository.ts | 14 +++++++ 6 files changed, 74 insertions(+), 31 deletions(-) create mode 100644 service/src/repository/SearchListRepository.ts diff --git a/component/src/stories/ChatBubble/ChatBubble.tsx b/component/src/stories/ChatBubble/ChatBubble.tsx index 1faac396..ed4b0baf 100644 --- a/component/src/stories/ChatBubble/ChatBubble.tsx +++ b/component/src/stories/ChatBubble/ChatBubble.tsx @@ -4,9 +4,10 @@ import { isSearchClickedState } from '@monorepo/service/src/recoil/atom/isSearch import { useSetRecoilState } from 'recoil'; import { useEffect, useRef } from 'react'; import { Link } from 'react-router-dom'; +import SearchIcon from '@mui/icons-material/Search'; +import CloseIcon from '@mui/icons-material/Close'; -export default function ChatBubble({ onSearchValue }: any) { - console.log(onSearchValue); +export default function ChatBubble({ onSearchResult }: any) { const setIsSearchClicked = useSetRecoilState(isSearchClickedState); const searchBoxRef = useRef(null); @@ -34,29 +35,42 @@ export default function ChatBubble({ onSearchValue }: any) {
-
-
- {onSearchValue.length !== 1 && - onSearchValue?.map((value: string, index: number) => ( -

{value}

- ))} -
+
+

RECENT SEARCHES

+
+ {onSearchResult.length !== 1 && + onSearchResult?.map((value: string, index: number) => ( +
+ +

+ + {value} +

+
+ +
+ +
+ ))} +
-

+

Explore topics

-
+
-
diff --git a/component/src/stories/header/Header.tsx b/component/src/stories/header/Header.tsx index f65e68b9..5f41add2 100644 --- a/component/src/stories/header/Header.tsx +++ b/component/src/stories/header/Header.tsx @@ -19,6 +19,7 @@ import { getHeaderKeywordSearch } from '@monorepo/service/src/service/HeaderSear import { HeaderSearchDataState } from '@monorepo/service/src/recoil/atom/HeaderSearchDataState'; import { PageState } from '@monorepo/service/src/recoil/atom/PageState'; import { Link, useNavigate } from 'react-router-dom'; +import { getSearchListStorage } from '@monorepo/service/src/repository/SearchListRepository'; const InputTextField = styled(TextField)({ '& label': { @@ -101,17 +102,20 @@ interface IHeaderProps { export default function Header({ authToken }: IHeaderProps) { const [isSearchClicked, setIsSearchClicked] = useRecoilState(isSearchClickedState); const [searchValue, setSearchValue] = useState(''); - const [valueReset, setValueRest] = useState(false); - const [resultSearchValue, setResultSearchValue] = useState([]); + + const [getSearchLocalResult, setGetSearchLocalResult] = useState([]); const setTechBlogSearchData = useSetRecoilState(HeaderSearchDataState); const { darkMode, toggleDarkMode } = useDarkMode(); const page = useRecoilValue(PageState); - const setProfileOpen = useSetRecoilState(profileModalOpen); + const KEY = 'search'; + const navigate = useNavigate(); const size = 10; const sort = 'writtenAt'; const direction = 'desc'; + const searchItem = getSearchListStorage(KEY); + async function getKeywordSerchRender() { const keywordSearchData = await getHeaderKeywordSearch({ page, @@ -122,15 +126,14 @@ export default function Header({ authToken }: IHeaderProps) { }); setTechBlogSearchData(prev => [...prev, ...keywordSearchData.content]); } + const handleKeyPress = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { // 엔터 키를 눌렀을 때 실행할 동작 setTechBlogSearchData([]); getKeywordSerchRender(); - setValueRest(true); setIsSearchClicked(false); - setResultSearchValue(prev => [...prev, ...resultSearchValue]); - navigate('/search', { state: searchValue }); + navigate(`/search/${searchValue}`, { state: searchValue }); } }; @@ -153,6 +156,10 @@ export default function Header({ authToken }: IHeaderProps) { getKeywordSerchRender(); }, [page]); + useEffect(() => { + setGetSearchLocalResult(searchItem); + }, [searchItem]); + return (
- +
@@ -172,12 +179,12 @@ export default function Header({ authToken }: IHeaderProps) { label="검색" aria-label="검색" onClick={() => setIsSearchClicked(!isSearchClicked)} - onKeyPress={e => handleKeyPress(e)} + onKeyDown={e => handleKeyPress(e)} onChange={e => handleSearchValue(e)} autoComplete="off" - value={!valueReset ? searchValue : ''} + value={searchValue} /> - {isSearchClicked && } + {isSearchClicked && }
diff --git a/service/src/App.tsx b/service/src/App.tsx index 358bc762..b6bce6ca 100644 --- a/service/src/App.tsx +++ b/service/src/App.tsx @@ -42,7 +42,7 @@ function App() { } /> } /> } /> - } /> + } /> } /> } /> diff --git a/service/src/components/modal/CategoryModal.tsx b/service/src/components/modal/CategoryModal.tsx index 4463120f..15071a7b 100644 --- a/service/src/components/modal/CategoryModal.tsx +++ b/service/src/components/modal/CategoryModal.tsx @@ -170,7 +170,7 @@ function CategoryModal({ onModalOpen, onClose }: CategoryProps) { ))} + + + ); +}; +const SignupForm = () => { + return ( + + +
+

시작하기

+
+ +
+

+ 지금 로그인하고 매일 새로운 기술블로그 소식을 전달받아보세요 +

+
+ + +
+ + + + + + +
+
+ ); +}; + +export default SelectCategoryForm; diff --git a/service/src/components/signup/SingUpForm.tsx b/service/src/components/signup/SingUpForm.tsx index f59ddf4a..32b7f5d1 100644 --- a/service/src/components/signup/SingUpForm.tsx +++ b/service/src/components/signup/SingUpForm.tsx @@ -6,7 +6,7 @@ import { userInformationState } from '../../recoil/atom/userInformationState'; import { SignUpEmail, SignUpEmailValidation } from '../../service/auth/SocialService'; import { providerIdState } from '../../recoil/atom/providerIdState'; import useDebounce from '../../hooks/useDebounce'; -import { ValueProps, IParentProps } from './type'; +import { IParentProps, ValueProps } from './type'; import EmailInput from './EmailInput'; const msg = { @@ -30,7 +30,7 @@ export default function SingUpForm({ onSignupNext }: IParentProps) { const providerId = useRecoilValue(providerIdState); const debouncedInputValue = useDebounce(emailCodeValue, { delay: 500 }); - async function emailVaildationRender() { + async function emailVailidationRender() { if (emailCodeValue !== '') { const emailStatusData = await SignUpEmailValidation({ providerId, diff --git a/service/src/components/social/SocialLogin.tsx b/service/src/components/social/SocialLogin.tsx index f97b7076..8ec4af75 100644 --- a/service/src/components/social/SocialLogin.tsx +++ b/service/src/components/social/SocialLogin.tsx @@ -1,30 +1,22 @@ import { useSetRecoilState } from 'recoil'; import github from '../../assets/github.webp'; import kakao from '../../assets/kakao.webp'; -import { setProvider } from '../../repository/ProviderRepository'; +import { getUrlByProvider, setProvider } from '../../repository/ProviderRepository'; import { IProps } from './type'; import { isLoggedInState } from '../../recoil/atom/isLoggedInState'; +import { OAuth2Provider } from '../../constants/auth'; export default function SocialLogin({ state }: IProps) { - const GITHUB_CLIENT_ID = import.meta.env.VITE_APP_GITGUB_CLIENT_ID; //REST API KEY - const GITHUB_REDIRECT_URL = import.meta.env.VITE_APP_GITHUB_REDIRECT_URL; //REDIRECT_URL - const KAKAO_REST_API_KEY = import.meta.env.VITE_APP_KAKAO_REST_API_KEY; //REST API KEY - const KAKAO_REDIRECT_URL = import.meta.env.VITE_APP_KAKAO_REDIRECT_URL; //현 프로젝트에서는 백엔드에서 REDIRECT_URL 처리 const setLoggedIn = useSetRecoilState(isLoggedInState); const handleLogin = () => { - if (state === 'github') { - const url = `https://github.com/login/oauth/authorize?client_id=${GITHUB_CLIENT_ID}&redirect_url=${GITHUB_REDIRECT_URL}`; - setProvider('github'); - setLoggedIn(true); - locationUrl(url); - } else { - const url = `https://kauth.kakao.com/oauth/authorize?client_id=${KAKAO_REST_API_KEY}&redirect_uri=${KAKAO_REDIRECT_URL}&response_type=code`; - setProvider('kakao'); - setLoggedIn(true); - locationUrl(url); - } + const url = getUrlByProvider(state); + setLoggedIn(true); + setProvider(OAuth2Provider[state]); + locationUrl(url); }; + const computedImageSet = state === 'GITHUB' ? github : kakao; + const locationUrl = (url: string) => { window.location.assign(url); }; @@ -35,33 +27,18 @@ export default function SocialLogin({ state }: IProps) { className="bg-transparent border-none hover:border-none focus:border-none focus:outline-none" onClick={handleLogin} > - {state === 'github' ? ( - - - github login - - ) : ( - - - kakao login - - )} + + + {state +
); diff --git a/service/src/components/social/type.ts b/service/src/components/social/type.ts index 8d1c067f..0aa83280 100644 --- a/service/src/components/social/type.ts +++ b/service/src/components/social/type.ts @@ -1,3 +1,5 @@ +import { OAuth2ProviderType } from '../../types/Auth.type.ts'; + export interface IProps { - state: string; + state: OAuth2ProviderType; } diff --git a/service/src/constants/api.ts b/service/src/constants/api.ts new file mode 100644 index 00000000..9d908e8a --- /dev/null +++ b/service/src/constants/api.ts @@ -0,0 +1,7 @@ +export const API_ENDPOINTS = { + SIGNUP: '/api/v1/auth/signup', + SIGNIN: '/api/v1/auth/signin', + OAUTH2_PROFILE: '/api/v1/auth/oauth2/profile', + + ME: '/api/v1/members/me', +} as const; diff --git a/service/src/constants/auth.ts b/service/src/constants/auth.ts new file mode 100644 index 00000000..70f4f3ff --- /dev/null +++ b/service/src/constants/auth.ts @@ -0,0 +1,11 @@ +import { OAuth2ProviderType } from '../types/Auth.type.ts'; + +export const OAuth2Provider: Record = { + GITHUB: 'github', + KAKAO: 'kakao', +} as const; + +export const OAuth2ProviderText: Record = { + KAKAO: '카카오로 시작하기', + GITHUB: '깃허브로 시작하기', +}; diff --git a/service/src/contexts/MemberProfileContext.tsx b/service/src/contexts/MemberProfileContext.tsx new file mode 100644 index 00000000..d29be708 --- /dev/null +++ b/service/src/contexts/MemberProfileContext.tsx @@ -0,0 +1,58 @@ +import { createContext, PropsWithChildren, useContext, useMemo } from 'react'; +import { useFetch } from '../hooks/useFetch.ts'; +import { getMe } from '../service/member/GetMe.ts'; + +interface MemberProfile {} + +type Actions = { + refetchMemberInfo: () => void; + clearMemberInfo: () => void; +}; +const MemberProfileContext = createContext(null); +const MemberProfileActionContext = createContext(null); + +const MemberProfileProvider = ({ children }: PropsWithChildren) => { + const { result, clearResult, refetch } = useFetch(() => getMe(), { + errorBoundary: false, + enabled: localStorage.getItem('accessToken') !== null, + }); + + const actions = useMemo( + () => ({ + refetchMemberInfo: refetch, + clearMemberInfo: () => { + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + }, + }), + [clearResult, refetch], + ); + + return ( + + + {children} + + + ); +}; + +export default MemberProfileProvider; + +export const useMemberProfile = () => { + const memberProfile = useContext(MemberProfileContext); + + return { + memberProfile, + isLoggedIn: !!memberProfile, + }; +}; +export const useMemberInfoAction = () => { + const value = useContext(MemberProfileActionContext); + + if (value === null) { + throw new Error('MemberInfoAction 에러'); + } + + return value; +}; diff --git a/service/src/hooks/commons/useMyProfile.ts b/service/src/hooks/commons/useMyProfile.ts new file mode 100644 index 00000000..e1f0a0a3 --- /dev/null +++ b/service/src/hooks/commons/useMyProfile.ts @@ -0,0 +1,11 @@ +import { useFetch } from '../useFetch.ts'; +import { getMe } from '../../service/member/GetMe.ts'; + +export const useMyProfile = () => { + const data = useFetch(() => getMe(), { + suspense: false, + refetchInterval: 2000, + }); + + return data.result; +}; diff --git a/service/src/hooks/useFetch.ts b/service/src/hooks/useFetch.ts new file mode 100644 index 00000000..4738beab --- /dev/null +++ b/service/src/hooks/useFetch.ts @@ -0,0 +1,84 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +type Status = 'pending' | 'fulfilled' | 'error'; + +interface Options { + enabled?: boolean; + suspense?: boolean; + errorBoundary?: boolean; + refetchInterval?: number; + + onSuccess?: (result: T) => void; + onError?: (error: Error) => void; +} + +export const useFetch = ( + request: () => Promise, + { + enabled = true, + suspense = true, + errorBoundary = true, + refetchInterval, + onError, + onSuccess, + }: Options, +) => { + const [status, setStatus] = useState('pending'); + const [promise, setPromise] = useState>(null); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const interval = useRef(null); + + const resolvePromise = useCallback( + (newResult: T) => { + setStatus('fulfilled'); + setResult(newResult); + onSuccess?.(newResult); + }, + [onSuccess], + ); + + const rejectPromise = useCallback( + (error: Error) => { + setStatus('error'); + setError(error); + onError?.(error); + }, + [onError], + ); + + const fetch = useCallback(() => { + setStatus('pending'); + const requestPromise = request().then(resolvePromise, rejectPromise); + setPromise(requestPromise); + return requestPromise; + }, []); + + const clearResult = () => setResult(null); + + const clearIntervalRef = () => { + if (interval.current === null) return; + clearInterval(interval.current); + interval.current = null; + }; + + useEffect(() => { + if (!enabled) return; + + fetch(); + + if (refetchInterval) { + interval.current = setInterval(fetch, refetchInterval); + return clearIntervalRef; + } + }, [enabled, fetch]); + + if (suspense && status === 'pending' && promise) { + throw promise; + } + if (errorBoundary && status === 'error') { + throw error; + } + + return { result, isLoading: status === 'pending', error: error, clearResult, refetch: fetch }; +}; diff --git a/service/src/hooks/useIntersectionObserver.tsx b/service/src/hooks/useIntersectionObserver.tsx index 267cb9ef..e469dc7d 100644 --- a/service/src/hooks/useIntersectionObserver.tsx +++ b/service/src/hooks/useIntersectionObserver.tsx @@ -4,16 +4,16 @@ export const useIntersectionObserver = (callback: () => void) => { const [observationTarget, setObservationTarget] = useState(null); const observer = useRef(null); useEffect(() => { - if (observationTarget) { - observer.current = new IntersectionObserver( - ([entry]) => { - if (!entry.isIntersecting) return; - callback(); - }, - { threshold: 1 }, - ); - observer.current.observe(observationTarget); - } + if (!observationTarget) return; + + observer.current = new IntersectionObserver( + ([entry]) => { + if (!entry.isIntersecting) return; + callback(); + }, + { threshold: 1 }, + ); + observer.current.observe(observationTarget); return () => { if (observer.current && observationTarget) { observer.current.unobserve(observationTarget); diff --git a/service/src/hooks/useModal.ts b/service/src/hooks/useModal.ts new file mode 100644 index 00000000..908cd665 --- /dev/null +++ b/service/src/hooks/useModal.ts @@ -0,0 +1,22 @@ +import { useState } from 'react'; + +interface IUseModal { + open: boolean; + handleOpen: () => void; + handleClose: () => void; +} + +const useModal = (defaultValue = false): IUseModal => { + const [open, setOpen] = useState(defaultValue); + + const handleOpen = () => setOpen(() => true); + const handleClose = () => setOpen(() => false); + + return { + open, + handleOpen, + handleClose, + }; +}; + +export default useModal; diff --git a/service/src/hooks/useMutation.ts b/service/src/hooks/useMutation.ts new file mode 100644 index 00000000..f70b24f1 --- /dev/null +++ b/service/src/hooks/useMutation.ts @@ -0,0 +1,49 @@ +import { useEffect, useState } from 'react'; + +type Options = { + errorBoundary?: boolean; + + onSuccess?: (result: T) => void | Promise; + onError?: (error: Error) => void; +}; + +const useMutation = ( + request: () => Promise, + { errorBoundary = true, onSuccess, onError }: Options = {}, +) => { + const [result, setResult] = useState()(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const mutate = async () => { + setIsLoading(true); + + try { + const result = await request(); + setResult(result); + await onSuccess?.(result); + return result; + } catch (reason) { + if (reason instanceof Error) { + setError(reason); + onError?.(reason); + } + throw reason; + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + if (errorBoundary && error) throw error; + }, [error, errorBoundary]); + + return { + mutate, + result, + isLoading, + error, + }; +}; + +export default useMutation; diff --git a/service/src/pages/KakaoAuthRedirectPage.tsx b/service/src/pages/KakaoAuthRedirectPage.tsx new file mode 100644 index 00000000..8d6b9a05 --- /dev/null +++ b/service/src/pages/KakaoAuthRedirectPage.tsx @@ -0,0 +1,62 @@ +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { OAuth2Provider } from '../constants/auth.ts'; +import { useCallback, useEffect } from 'react'; +import requestOAuthProfile from '../service/auth/requestOAuthProfile.ts'; +import signin from '../service/auth/post/Signin.ts'; +import { useMemberInfoAction } from '../contexts/MemberProfileContext.tsx'; +import Modal from '@mui/material/Modal'; +import useModal from '../hooks/useModal.ts'; +import SignupForm from '../components/signup/SignupForm.tsx'; + +const KAKAO_CODE = 'code'; +const state = OAuth2Provider.KAKAO; + +const SignupFormModal = () => { + const { open, handleClose } = useModal(true); + return ( + + + + ); +}; +const KakaoAuthRedirectPage = () => { + const [params] = useSearchParams(); + const code = params.get(KAKAO_CODE); + const { refetchMemberInfo: fetchMemberProfile } = useMemberInfoAction(); + + const navigate = useNavigate(); + + if (code === null) { + navigate('/'); + return; + } + + const requestAuthToken = useCallback(async () => { + const response = await requestOAuthProfile({ code, state }); + + if (!response.isRegistered) return; + + const { accessToken, refreshToken } = await signin({ providerId: response.providerId }); + + window.localStorage.setItem('accessToken', accessToken); + window.localStorage.setItem('refreshToken', refreshToken); + + fetchMemberProfile(); + navigate('/'); + }, [code, navigate, fetchMemberProfile]); + + useEffect(() => { + requestAuthToken(); + }, [requestAuthToken]); + + console.log(code); + + return ; +}; + +export default KakaoAuthRedirectPage; diff --git a/service/src/pages/MainPage.tsx b/service/src/pages/MainPage.tsx index 75fe8782..5e7ffec7 100644 --- a/service/src/pages/MainPage.tsx +++ b/service/src/pages/MainPage.tsx @@ -47,7 +47,6 @@ export default function MainPage() { const userFilterTechBlogData = await getUserTechBlogService({ page, size, - id, }); setFilterTechBlogData(prev => [...prev, ...userFilterTechBlogData.content]); diff --git a/service/src/repository/AuthRepository.ts b/service/src/repository/AuthRepository.ts index adc42b9f..a09b4478 100644 --- a/service/src/repository/AuthRepository.ts +++ b/service/src/repository/AuthRepository.ts @@ -2,6 +2,10 @@ export function getAuthStorage(key: string) { return localStorage.getItem(key); } +export function getAccessToken() { + return localStorage.getItem('access_token'); +} + export function setAuthStorage( accessToken_key: string, accessTokenValue: string, diff --git a/service/src/repository/ProviderRepository.ts b/service/src/repository/ProviderRepository.ts index 95714b47..cd65ab88 100644 --- a/service/src/repository/ProviderRepository.ts +++ b/service/src/repository/ProviderRepository.ts @@ -1,3 +1,9 @@ +import type { OAuth2ProviderType } from '../types/Auth.type.ts'; + +const GITHUB_CLIENT_ID = import.meta.env.VITE_APP_GITGUB_CLIENT_ID; //REST API KEY +const GITHUB_REDIRECT_URL = import.meta.env.VITE_APP_GITHUB_REDIRECT_URL; //REDIRECT_URL +const KAKAO_REST_API_KEY = import.meta.env.VITE_APP_KAKAO_REST_API_KEY; //REST API KEY +const KAKAO_REDIRECT_URL = import.meta.env.VITE_APP_KAKAO_REDIRECT_URL; //현 프로젝트에서는 백엔드에서 REDIRECT_URL 처리 export function setProvider(value: string) { localStorage.setItem('provider', value); } @@ -9,3 +15,14 @@ export function getProvider(value: string) { export function removeProvider() { localStorage.removeItem('provider'); } + +export const getUrlByProvider = (provider: OAuth2ProviderType) => { + if (provider === 'GITHUB') { + return `https://github.com/login/oauth/authorize?client_id=${GITHUB_CLIENT_ID}&redirect_url=${GITHUB_REDIRECT_URL}`; + } + if (provider === 'KAKAO') { + return `https://kauth.kakao.com/oauth/authorize?client_id=${KAKAO_REST_API_KEY}&redirect_uri=${KAKAO_REDIRECT_URL}&response_type=code`; + } + + throw new Error('존재하지 않는 provider'); +}; diff --git a/service/src/service/auth/SocialService.ts b/service/src/service/auth/SocialService.ts index d2ca03a9..ff20dcda 100644 --- a/service/src/service/auth/SocialService.ts +++ b/service/src/service/auth/SocialService.ts @@ -1,5 +1,5 @@ import HttpClient from '../../apis/HttpClient'; -import { IAuthProps, IAuthEmailProps, IAuthEmailVaildationProps } from './type'; +import { IAuthEmailProps, IAuthEmailVaildationProps, IAuthProps } from './type'; export const SocialService = async (code: string | null, state: string) => { try { @@ -21,6 +21,10 @@ export async function SignInService(providerValue: string) { } } +export async function withdrawMembership() { + await HttpClient.delete('/api/v1/auth/members/me/deletion'); +} + export async function SignUpService({ email, categoryIds, diff --git a/service/src/service/auth/post/Signin.ts b/service/src/service/auth/post/Signin.ts new file mode 100644 index 00000000..4534843f --- /dev/null +++ b/service/src/service/auth/post/Signin.ts @@ -0,0 +1,9 @@ +import { SigninRequest, SigninResponse } from '../../../types/Auth.type.ts'; +import httpClient from '../../../apis/HttpClient.tsx'; +import { API_ENDPOINTS } from '../../../constants/api.ts'; + +const signin = async (request: SigninRequest) => { + return (await httpClient.post(API_ENDPOINTS.SIGNIN, request)).data; +}; + +export default signin; diff --git a/service/src/service/auth/post/Signup.ts b/service/src/service/auth/post/Signup.ts new file mode 100644 index 00000000..5934274d --- /dev/null +++ b/service/src/service/auth/post/Signup.ts @@ -0,0 +1,8 @@ +import httpClient from '../../../apis/HttpClient.tsx'; +import { API_ENDPOINTS } from '../../../constants/api.ts'; + +const signup = () => { + httpClient.post(API_ENDPOINTS.SIGNUP); +}; + +export default signup; diff --git a/service/src/service/auth/requestOAuthProfile.ts b/service/src/service/auth/requestOAuthProfile.ts new file mode 100644 index 00000000..18476b68 --- /dev/null +++ b/service/src/service/auth/requestOAuthProfile.ts @@ -0,0 +1,13 @@ +import { Oauth2ProfileRequest, Oauth2ProfileResponse } from '../../types/Auth.type.ts'; +import httpClient from '../../apis/HttpClient.tsx'; +import { API_ENDPOINTS } from '../../constants/api.ts'; + +const requestOAuthProfile = async (request: Oauth2ProfileRequest) => { + return ( + await httpClient.get(API_ENDPOINTS.OAUTH2_PROFILE, { + params: request, + }) + ).data; +}; + +export default requestOAuthProfile; diff --git a/service/src/service/member/GetMe.ts b/service/src/service/member/GetMe.ts new file mode 100644 index 00000000..d510bed6 --- /dev/null +++ b/service/src/service/member/GetMe.ts @@ -0,0 +1,5 @@ +import HttpClient from '../../apis/HttpClient.tsx'; +import { API_ENDPOINTS } from '../../constants/api.ts'; +import { MembersMeResponse } from '../../types/member.type.ts'; + +export const getMe = async () => (await HttpClient.get(API_ENDPOINTS.ME)).data; diff --git a/service/src/types/Auth.type.ts b/service/src/types/Auth.type.ts new file mode 100644 index 00000000..89fb0a9e --- /dev/null +++ b/service/src/types/Auth.type.ts @@ -0,0 +1,34 @@ +export type OAuth2ProviderType = 'GITHUB' | 'KAKAO'; + +interface Token { + accessToken: string; + refreshToken: string; +} + +export interface SignupRequest { + email: string; + categoryIds: number[]; + nickname: string; + provider: Lowercase; + providerId: string; + profileImageUrl: string; +} + +export interface SignupResponse extends Token {} + +export interface SigninRequest { + providerId: string; +} + +export interface SigninResponse extends Token {} + +export interface Oauth2ProfileRequest { + code: string; + state: string; +} + +export interface Oauth2ProfileResponse { + providerId: string; + profileImageUrl: string; + isRegistered: boolean; +} diff --git a/service/src/types/member.type.ts b/service/src/types/member.type.ts new file mode 100644 index 00000000..6c27f6d0 --- /dev/null +++ b/service/src/types/member.type.ts @@ -0,0 +1,12 @@ +import { OAuth2ProviderType } from './Auth.type.ts'; + +export interface MemberProfile { + id: number; + email: string; + nickname: string; + profileImageUrl: string; + provider: Lowercase; + providerId: string; +} + +export interface MembersMeResponse extends MemberProfile {}