diff --git a/component/src/stories/ChatBubble/HeaderSearchMenu.stories.ts b/component/src/stories/ChatBubble/HeaderSearchMenu.stories.ts new file mode 100644 index 00000000..a72984e7 --- /dev/null +++ b/component/src/stories/ChatBubble/HeaderSearchMenu.stories.ts @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import HeaderSearchMenu from './HeaderSearchMenu'; + +// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction#default-export +const meta = { + title: 'Example/HeaderSearchMenu', + component: HeaderSearchMenu, + 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 headerSearchMenu: Story = { + args: { + onSearchResult: () => {}, + onSetSearchResult: () => {}, + onSelectedSearchIndex: 0, // 초기값 설정 + onSetSearchValue: () => {}, + onSetGoToanotherPage: () => {}, + }, +}; diff --git a/component/src/stories/ChatBubble/HeaderSearchMenu.tsx b/component/src/stories/ChatBubble/HeaderSearchMenu.tsx new file mode 100644 index 00000000..b3a3acb2 --- /dev/null +++ b/component/src/stories/ChatBubble/HeaderSearchMenu.tsx @@ -0,0 +1,115 @@ +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 React, { useEffect, useRef } from 'react'; +import { Link } from 'react-router-dom'; +import SearchIcon from '@mui/icons-material/Search'; +import CloseIcon from '@mui/icons-material/Close'; +import { + getSearchListStorage, + saveSearchListStorage, +} from '@monorepo/service/src/repository/SearchListRepository'; + +interface ISearchProps { + onSearchResult: any; + onSetSearchResult: React.Dispatch>; + + onSetSearchValue: React.Dispatch>; + onSelectedSearchIndex: number; +} + +export default function HeaderSearchMenu({ + onSearchResult, + onSetSearchResult, + onSelectedSearchIndex, + onSetSearchValue, +}: ISearchProps) { + const setIsSearchClicked = useSetRecoilState(isSearchClickedState); + const searchBoxRef = useRef(null); + const KEY = 'search'; + + const handleCloseSearchResult = (num: number) => { + const getRecentSearchesData = getSearchListStorage(KEY); + onSetSearchValue(''); + const filterRecentSearchesData = getRecentSearchesData.filter( + (e: string, index: number) => index !== num, + ); + onSetSearchResult(filterRecentSearchesData); + saveSearchListStorage(KEY, filterRecentSearchesData); + }; + + 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 ( +
+
+
+
+

RECENT SEARCHES

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

+ + {value} +

+ +
+ { + e.preventDefault(); + handleCloseSearchResult(index); + }} + /> +
+
+ ))} + + +

+ + Explore topics +

+ + +
+
+
+
+
+ ); +} diff --git a/component/src/stories/header/Header.stories.ts b/component/src/stories/header/Header.stories.ts index e60d7739..603df4bd 100644 --- a/component/src/stories/header/Header.stories.ts +++ b/component/src/stories/header/Header.stories.ts @@ -19,4 +19,8 @@ export default meta; type Story = StoryObj; // More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args -export const header: Story = {}; +export const header: Story = { + args: { + authToken: '1111', + }, +}; diff --git a/component/src/stories/header/Header.tsx b/component/src/stories/header/Header.tsx index 148d4db1..54d108a8 100644 --- a/component/src/stories/header/Header.tsx +++ b/component/src/stories/header/Header.tsx @@ -1,21 +1,26 @@ +/* eslint-disable react-hooks/rules-of-hooks */ import styled from '@emotion/styled'; -import { TextField, IconButton, Avatar, Button } from '@mui/material'; +import {Avatar, Button, IconButton, TextField} from '@mui/material'; import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive'; -import { Login } from '@monorepo/component/src/stories/login/Login'; -import { useDarkMode } from '@monorepo/service/src/ThemeContext/ThemeProvider'; -import { DarkModeOutlined, LightModeOutlined } from '@mui/icons-material'; -import { removeAuthStorage } from '@monorepo/service/src/repository/AuthRepository'; -import { - getProfileImgStorage, - removeProfileImgStorage, -} from '@monorepo/service/src/repository/ProfileimgRepository'; -import { useRecoilState, useSetRecoilState } from 'recoil'; -import { profileModalOpen } from '@monorepo/service/src/recoil/atom/profileModalOpen'; -import { isLoggedInState } from '@monorepo/service/src/recoil/atom/isLoggedInState'; +import {Login} from '@monorepo/component/src/stories/login/Login'; +import {useDarkMode} from '@monorepo/service/src/ThemeContext/ThemeProvider'; +import {DarkModeOutlined, LightModeOutlined} from '@mui/icons-material'; +import {getAuthStorage, removeAuthStorage} from '@monorepo/service/src/repository/AuthRepository'; +import {getProfileImgStorage, removeProfileImgStorage,} from '@monorepo/service/src/repository/ProfileimgRepository'; +import {useRecoilState, useRecoilValue, useSetRecoilState} from 'recoil'; +import {profileModalOpen} from '@monorepo/service/src/recoil/atom/profileModalOpen'; +import {isLoggedInState} from '@monorepo/service/src/recoil/atom/isLoggedInState'; import darkLogo from '@monorepo/service/src/assets/darkLogo.webp'; import lightLogo from '@monorepo/service/src/assets/lightLogo.webp'; -import { useEffect } from 'react'; -import { getAuthStorage } from '@monorepo/service/src/repository/AuthRepository'; +import HeaderSearchMenu from '../ChatBubble/HeaderSearchMenu'; +import {isSearchClickedState} from '@monorepo/service/src/recoil/atom/isSearchClickedState'; +import {useEffect, useState} from 'react'; +import {getHeaderKeywordSearch} from '@monorepo/service/src/service/HeaderSearchService'; +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'; +import useHandleKeyPress from '@monorepo/service/src/hooks/useHandleKeyPress'; const InputTextField = styled(TextField)({ '& label': { @@ -49,6 +54,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; @@ -60,6 +67,7 @@ function AuthHeader({ onLogout }: IHandleProps) { //위 코드가 없을 경우 header 영역 밖을 클릭할 경우의 코드를 넣으면 profilediv 영역이 나타나지 않는다. e.stopPropagation(); setProfileOpen(!profileOpen); + setIsSearchClicked(false); }; return ( @@ -87,12 +95,20 @@ function AuthHeader({ onLogout }: IHandleProps) { } export default function Header() { + const [isSearchClicked, setIsSearchClicked] = useRecoilState(isSearchClickedState); + const [searchValue, setSearchValue] = useState(''); + const [getSearchLocalResult, setGetSearchLocalResult] = useState([]); + const [selectedSearchIndex, setSelectedSearchIndex] = useState(-1); + const setTechBlogSearchData = useSetRecoilState(HeaderSearchDataState); const { darkMode, toggleDarkMode } = useDarkMode(); + const page = useRecoilValue(PageState); const setProfileOpen = useSetRecoilState(profileModalOpen); const [loggedIn, setLoggedIn] = useRecoilState(isLoggedInState); const TOKEN_KEY = 'accessToken'; const token = getAuthStorage(TOKEN_KEY); - + const KEY = 'search'; + const size = 10; + const navigate = useNavigate(); const handleModalClose = () => { setProfileOpen(false); }; @@ -104,34 +120,110 @@ export default function Header() { setLoggedIn(false); }; + const searchItem = getSearchListStorage(KEY); + + async function getKeywordSerchRender() { + const keywordSearchData = await getHeaderKeywordSearch({ + page, + size, + searchValue, + }); + setTechBlogSearchData(prev => [...prev, ...keywordSearchData.content]); + } + + const handleKeyPress = (e: React.KeyboardEvent) => { + const value = (e.target as HTMLInputElement).value; + const key = e.key; + if (value !== '' && searchValue !== '') { + if (e.key === 'Enter') { + // 엔터 키를 눌렀을 때 실행할 동작 + setTechBlogSearchData([]); + getKeywordSerchRender(); + setGetSearchLocalResult(prev => { + const uniqueValuesSet = new Set([...prev, value]); + const uniqueValuesArray = Array.from(uniqueValuesSet); + return uniqueValuesArray; + }); + setIsSearchClicked(false); + navigate(`/search/${searchValue}`); + } + } + + useHandleKeyPress({ + key, + e, + getSearchLocalResult, + setSearchValue, + selectedSearchIndex, + setSelectedSearchIndex, + }); + }; + const handleInputClicked = (e: React.MouseEvent) => { + setIsSearchClicked(!isSearchClicked); + + setSearchValue((e.target as HTMLInputElement).value); + }; + + const handleSearchValue = (e: React.ChangeEvent) => { + const value = e.target.value; + setSearchValue(value); + }; + useEffect(() => { if (token) { setLoggedIn(true); } }, [token]); + useEffect(() => { + getKeywordSerchRender(); + }, [page]); + + useEffect(() => { + setGetSearchLocalResult(searchItem); + }, []); + + useEffect(() => { + if (selectedSearchIndex === -1) setSearchValue(''); + }, [selectedSearchIndex]); return ( -
+
- {darkMode === 'light' ? ( - 로고 - ) : ( - 로고 + + {darkMode === 'light' ? ( + 로고 + ) : ( + 로고 + )} + +
+
+ handleInputClicked(e)} + onKeyDown={e => handleKeyPress(e)} + onChange={e => handleSearchValue(e)} + autoComplete="off" + value={searchValue} + /> + {isSearchClicked && ( + )}
-
diff --git a/component/src/stories/listbox/Listbox.tsx b/component/src/stories/listbox/Listbox.tsx index f3575af6..e51a91e9 100644 --- a/component/src/stories/listbox/Listbox.tsx +++ b/component/src/stories/listbox/Listbox.tsx @@ -4,7 +4,7 @@ import { IListBoxProps } from './type'; export default function ListBox({ items, onCategoryId, onFilterItems }: IListBoxProps) { return ( <> - {onCategoryId === 0 ? ( + {onCategoryId === 0 || onCategoryId === undefined ? (
{items.map((item: any) => ( diff --git a/component/src/stories/listbox/ListboxItem.tsx b/component/src/stories/listbox/ListboxItem.tsx index 56fba891..489ef3a6 100644 --- a/component/src/stories/listbox/ListboxItem.tsx +++ b/component/src/stories/listbox/ListboxItem.tsx @@ -1,6 +1,8 @@ import { ItemProps } from './type'; export default function ListboxItem({ item }: ItemProps) { + console.log(item); + return ( <>
- - }> - } /> - } /> - } /> - } /> - - }> - } /> - } /> - + + + }> + } /> + } /> + } /> + } /> + } /> + + }> + } /> + } /> + + ); } diff --git a/service/src/ThemeContext/ThemeProvider.tsx b/service/src/ThemeContext/ThemeProvider.tsx index d962f6a8..c7a26b21 100644 --- a/service/src/ThemeContext/ThemeProvider.tsx +++ b/service/src/ThemeContext/ThemeProvider.tsx @@ -1,24 +1,25 @@ import React, { createContext, - useContext, - useState, ReactNode, - useLayoutEffect, + useContext, useEffect, + useLayoutEffect, + useState, } from 'react'; import { ThemeProvider as MuiThemeProvider } from '@mui/material/styles'; import CssBaseline from '@mui/material/CssBaseline'; import { - setDarkModeSotrage, getDarkModeStorage, removeDarkModeStorage, + setDarkModeSotrage, } from '../repository/DarkRepository'; import createCustomTheme from './theme'; interface ThemeContextProps { darkMode: string; toggleDarkMode: () => void; + isDarkMode: () => boolean; } const DarkModeContext = createContext(undefined); @@ -36,6 +37,8 @@ const ThemeProvider: React.FC = ({ children }) => { setDarkMode(prev => (prev === 'light' ? 'dark' : 'light')); }; + const isDarkMode = () => darkMode === 'dark'; + useEffect(() => { removeDarkModeStorage(); setDarkModeSotrage(darkMode); @@ -74,7 +77,7 @@ const ThemeProvider: React.FC = ({ children }) => { console.log(darkMode); return ( - + +

가장 많이 검색된 기술

diff --git a/service/src/components/card/CardList.tsx b/service/src/components/card/CardList.tsx index 4098270a..6801f49f 100644 --- a/service/src/components/card/CardList.tsx +++ b/service/src/components/card/CardList.tsx @@ -4,8 +4,8 @@ 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/layout/Header/Header.tsx b/service/src/components/layout/Header/Header.tsx new file mode 100644 index 00000000..f203bd04 --- /dev/null +++ b/service/src/components/layout/Header/Header.tsx @@ -0,0 +1,16 @@ +// custom component +import HeaderSearchBar from './HeaderSearchBar'; +import HeaderMenu from './HeaderMenu.tsx'; + +const Header = () => { + return ( +
+
+ + +
+
+ ); +}; + +export default Header; diff --git a/service/src/components/layout/Header/HeaderMenu.tsx b/service/src/components/layout/Header/HeaderMenu.tsx new file mode 100644 index 00000000..197abc65 --- /dev/null +++ b/service/src/components/layout/Header/HeaderMenu.tsx @@ -0,0 +1,69 @@ +import { IconButton, Modal } from '@mui/material'; +import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive'; +import { useMemberProfile } from '../../../contexts/MemberProfileContext.tsx'; +import { useState } from 'react'; +import { BsPersonCircle } from 'react-icons/bs'; +import SelectLoginTypeForm from '../../selectLoginTypeForm/SelectLoginTypeForm.tsx'; + +const HeaderMenu = () => { + const { isLoggedIn } = useMemberProfile(); + + return ( +
+ + {isLoggedIn ? 'true' : 'false'} + + {isLoggedIn ? : } +
+ ); +}; + +export const Login = () => { + const [open, setOpen] = useState(true); + const handleOpen = () => setOpen(true); + const handleClose = () => { + setOpen(false); + }; + return ( + <> + + + + {/**/} + {/*
*/} + {/*
*/} + {/*

*/} + {/* */} + {/*

*/} + {/*
*/} + {/* */} + {/*

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

*/} + {/*
*/} + {/*
*/} + {/*
*/} + {/* */} + {/* */} + {/*
*/} + {/*
*/} + {/* */} + {/* */} + {/*
*/} + {/*
*/} + {/*
*/} + {/**/} + + + ); +}; + +export default HeaderMenu; diff --git a/service/src/components/layout/Header/HeaderSearchBar.tsx b/service/src/components/layout/Header/HeaderSearchBar.tsx new file mode 100644 index 00000000..6d87e42d --- /dev/null +++ b/service/src/components/layout/Header/HeaderSearchBar.tsx @@ -0,0 +1,20 @@ +import DrrrLogo from '../../logo/DrrrLogo.tsx'; +import { InputTextField } from '../../../style/inputText.ts'; + +const HeaderSearchBar = () => { + return ( +
+ + +
+ ); +}; + +export default HeaderSearchBar; diff --git a/service/src/components/layout/LayoutWIthAside.tsx b/service/src/components/layout/LayoutWIthAside.tsx index 0534a3a3..87628b34 100644 --- a/service/src/components/layout/LayoutWIthAside.tsx +++ b/service/src/components/layout/LayoutWIthAside.tsx @@ -1,6 +1,7 @@ import { Outlet } from 'react-router-dom'; import Aside from '../aside/Aside'; import Layout from './Layout'; +import { Divider } from '@mui/material'; export default function LayoutWithAside() { return ( @@ -10,6 +11,9 @@ export default function LayoutWithAside() { + + +