diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts index 629d5a808..20ef1ef8a 100644 --- a/frontend/.storybook/main.ts +++ b/frontend/.storybook/main.ts @@ -7,6 +7,7 @@ const config: StorybookConfig = { '@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-interactions', + 'storybook-addon-react-router-v6', ], framework: { name: '@storybook/react-webpack5', @@ -15,6 +16,9 @@ const config: StorybookConfig = { docs: { autodocs: true, }, + + staticDirs: ['../public'], + webpackFinal: async (config) => { if (config.resolve) { config.resolve.plugins = [ diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index 75edec7d1..4f5adb4dc 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -1,24 +1,101 @@ +import { initialize, mswLoader } from 'msw-storybook-addon'; import type { Preview } from '@storybook/react'; import GlobalStyles from '../src/shared/styles/GlobalStyles'; import { ThemeProvider } from 'styled-components'; import theme from '../src/shared/styles/theme'; +import { BrowserRouter } from 'react-router-dom'; +import AuthProvider from '@/features/auth/components/AuthProvider'; +import LoginPopupProvider from '@/features/auth/hooks/LoginPopUpContext'; +import handlers from '@/mocks/handlers'; + +const customViewport = { + xxl: { + name: 'xxl', + styles: { + width: '1440px', + height: '1080px', + }, + }, + + xl: { + name: 'xl', + styles: { + width: '1280px', + height: '720px', + }, + }, + + lg: { + name: 'lg', + styles: { + width: '1024px', + height: '720px', + }, + }, + + md: { + name: 'md', + styles: { + width: '768px', + height: '1024px', + }, + }, + + sm: { + name: 'sm', + styles: { + width: '640px', + height: '768px', + }, + }, + + xs: { + name: 'xs', + styles: { + width: '420px', + height: '768px', + }, + }, + + xxs: { + name: 'xxs', + styles: { + width: '380px', + height: '768px', + }, + }, +}; + +initialize(); const preview: Preview = { parameters: { actions: { argTypesRegex: '^on[A-Z].*' }, + viewport: { viewports: customViewport, defaultViewport: 'xs' }, controls: { matchers: { color: /(background|color)$/i, date: /Date$/, }, }, + msw: { + handlers: [...handlers], + }, }, + loaders: [mswLoader], + decorators: [ (Story) => ( - - - - + + + + + + + + + + ), ], }; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 99379959b..4cab444db 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -55,10 +55,12 @@ "jest": "^29.6.1", "jest-environment-jsdom": "^29.6.1", "msw": "^1.2.3", + "msw-storybook-addon": "^1.9.0", "postcss-styled-syntax": "^0.4.0", "prettier": "^3.0.0", "react-refresh": "^0.14.0", "storybook": "^7.0.27", + "storybook-addon-react-router-v6": "^2.0.7", "stylelint": "^15.10.2", "stylelint-config-clean-order": "^5.0.1", "ts-jest": "^29.1.1", @@ -9096,6 +9098,12 @@ "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", "dev": true }, + "node_modules/compare-versions": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.0.tgz", + "integrity": "sha512-LNZQXhqUvqUTotpZ00qLSaify3b4VFD588aRr8MKFw4CMUr98ytzCW5wDH5qx/DEY5kCDXcbcRuCqL0szEf2tg==", + "dev": true + }, "node_modules/compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", @@ -16928,6 +16936,18 @@ } } }, + "node_modules/msw-storybook-addon": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/msw-storybook-addon/-/msw-storybook-addon-1.9.0.tgz", + "integrity": "sha512-+5ki9SZYF0+IEMW9n4fzkuRa02o5lf9Xf6nfAvWqYvwdLtcpmcwdBRkkFTh+wLTZv010+Ui+P6ZYEVJ0e8wMyw==", + "dev": true, + "dependencies": { + "is-node-process": "^1.0.1" + }, + "peerDependencies": { + "msw": ">=0.35.0 <2.0.0" + } + }, "node_modules/msw/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -19808,6 +19828,36 @@ "url": "https://opencollective.com/storybook" } }, + "node_modules/storybook-addon-react-router-v6": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/storybook-addon-react-router-v6/-/storybook-addon-react-router-v6-2.0.7.tgz", + "integrity": "sha512-vky9WXG84fQjwx55KKFQdhyUC5AnfsGJSoYx/yaJi2q/oTDcCTkcwpxlcrSKpTpNtVjsFNnaS3cuWXX+Sfc8Vw==", + "dev": true, + "dependencies": { + "compare-versions": "^6.0.0", + "react-inspector": "6.0.2" + }, + "peerDependencies": { + "@storybook/blocks": "^7.0.0", + "@storybook/channels": "^7.0.0", + "@storybook/components": "^7.0.0", + "@storybook/core-events": "^7.0.0", + "@storybook/manager-api": "^7.0.0", + "@storybook/preview-api": "^7.0.0", + "@storybook/theming": "^7.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-router-dom": "^6.4.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/stream-shift": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 12bf77102..47ff45288 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -59,10 +59,12 @@ "jest": "^29.6.1", "jest-environment-jsdom": "^29.6.1", "msw": "^1.2.3", + "msw-storybook-addon": "^1.9.0", "postcss-styled-syntax": "^0.4.0", "prettier": "^3.0.0", "react-refresh": "^0.14.0", "storybook": "^7.0.27", + "storybook-addon-react-router-v6": "^2.0.7", "stylelint": "^15.10.2", "stylelint-config-clean-order": "^5.0.1", "ts-jest": "^29.1.1", diff --git a/frontend/src/assets/icon/left-arrow.svg b/frontend/src/assets/icon/left-arrow.svg new file mode 100644 index 000000000..5e7026763 --- /dev/null +++ b/frontend/src/assets/icon/left-arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/icon/search.svg b/frontend/src/assets/icon/search.svg new file mode 100644 index 000000000..45a77d91d --- /dev/null +++ b/frontend/src/assets/icon/search.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/src/features/auth/components/AuthProvider.tsx b/frontend/src/features/auth/components/AuthProvider.tsx index 005e4befd..60184918d 100644 --- a/frontend/src/features/auth/components/AuthProvider.tsx +++ b/frontend/src/features/auth/components/AuthProvider.tsx @@ -1,6 +1,7 @@ import { createContext, useContext, useMemo, useState } from 'react'; import accessTokenStorage from '@/shared/utils/accessTokenStorage'; import parseJWT from '../utils/parseJWT'; +import type { PropsWithChildren } from 'react'; interface User { memberId: number; @@ -23,7 +24,7 @@ export const useAuthContext = () => { const AuthContext = createContext(null); -const AuthProvider = ({ children }: { children: React.ReactElement[] }) => { +const AuthProvider = ({ children }: PropsWithChildren) => { const [accessToken, setAccessToken] = useState(accessTokenStorage.getToken() || ''); const user: User | null = useMemo(() => { diff --git a/frontend/src/features/auth/hooks/LoginPopUpContext.tsx b/frontend/src/features/auth/hooks/LoginPopUpContext.tsx index 3bec92351..16172bb82 100644 --- a/frontend/src/features/auth/hooks/LoginPopUpContext.tsx +++ b/frontend/src/features/auth/hooks/LoginPopUpContext.tsx @@ -12,7 +12,7 @@ const LoginPopUpContext = createContext(null); export const useLoginPopup = () => { const contextValue = useContext(LoginPopUpContext); - if (contextValue === null) throw new Error('AuthContext가 null입니다.'); + if (contextValue === null) throw new Error('LoginPopUpContext에 값이 제공되지 않았습니다.'); return contextValue; }; diff --git a/frontend/src/features/search/components/SearchBar.stories.tsx b/frontend/src/features/search/components/SearchBar.stories.tsx new file mode 100644 index 000000000..3b22a0237 --- /dev/null +++ b/frontend/src/features/search/components/SearchBar.stories.tsx @@ -0,0 +1,15 @@ +import Header from '@/shared/components/Layout/Header'; +import SearchBar from './SearchBar'; +import type { Meta, StoryObj } from '@storybook/react'; + +const meta = { + component: SearchBar, + title: 'SearchBar', +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () =>
, +}; diff --git a/frontend/src/features/search/components/SearchBar.tsx b/frontend/src/features/search/components/SearchBar.tsx new file mode 100644 index 000000000..cc3e3f7b1 --- /dev/null +++ b/frontend/src/features/search/components/SearchBar.tsx @@ -0,0 +1,156 @@ +import styled, { css } from 'styled-components'; +import cancelIcon from '@/assets/icon/cancel.svg'; +import backwardIcon from '@/assets/icon/left-arrow.svg'; +import searchIcon from '@/assets/icon/search.svg'; +import Flex from '@/shared/components/Flex/Flex'; +import useSearchBar from '../hooks/useSearchBar'; +import SearchPreviewSheet from './SearchPreviewSheet'; + +const SearchBar = () => { + const { + isSearching, + searchQuery, + inputRef, + singerSearchPreview, + startSearch, + endSearchOnBlur, + endSearch, + changeQuery, + resetQuery, + search, + } = useSearchBar(); + + const isQueryFilled = searchQuery.length !== 0; + + return ( + + + + {isQueryFilled && ( + + )} + + + {isSearching && ( + + )} + + ); +}; + +export default SearchBar; + +const searchButtonStyles = css` + width: 20px; + height: 20px; + background: url(${searchIcon}) transparent no-repeat; + background-size: contain; +`; + +const FlexSearchBox = styled(Flex)<{ $isSearching: boolean }>` + position: relative; + + height: 34px; + padding: 0 10px; + + background-color: ${({ theme }) => theme.color.black200}; + border-radius: 14px; + + transition: flex 0.2s ease; + + @media (max-width: ${({ theme }) => theme.breakPoints.md}) { + flex: ${({ $isSearching }) => $isSearching && 1}; + } +`; + +const BackwardButton = styled.button<{ $isSearching: boolean }>` + position: absolute; + top: 50%; + left: 10px; + transform: translate(0, -50%); + + display: none; + + width: 20px; + height: 20px; + + background: url(${backwardIcon}) transparent no-repeat; + background-size: contain; + + @media (max-width: ${({ theme }) => theme.breakPoints.md}) { + display: ${({ $isSearching }) => $isSearching && 'block'}; + } +`; + +const SearchInput = styled.input<{ $isSearching: boolean }>` + width: 220px; + padding: 0 40px 0 8px; + + color: white; + + background-color: transparent; + border: none; + outline: none; + + transition: width 0.3s ease; + + @media (max-width: ${({ theme }) => theme.breakPoints.md}) { + width: ${({ $isSearching }) => !$isSearching && 0}; + padding: ${({ $isSearching }) => ($isSearching ? '0 40px 0 28px' : 0)}; + visibility: ${({ $isSearching }) => !$isSearching && 'hidden'}; + } +`; + +const ResetQueryButton = styled.button<{ $isSearching: boolean }>` + position: absolute; + top: 50%; + right: 40px; + transform: translate(0, -50%); + + width: 26px; + height: 26px; + + background: url(${cancelIcon}) transparent no-repeat; + background-size: contain; + + @media (max-width: ${({ theme }) => theme.breakPoints.md}) { + display: ${({ $isSearching }) => !$isSearching && 'none'}; + } +`; + +const SearchButton = styled.button<{ $isSearching: boolean }>` + ${searchButtonStyles} + + @media (max-width: ${({ theme }) => theme.breakPoints.md}) { + display: ${({ $isSearching }) => !$isSearching && 'none'}; + } +`; + +const SearchBarExpandButton = styled.button<{ $isSearching: boolean }>` + ${searchButtonStyles} + display: none; + + @media (max-width: ${({ theme }) => theme.breakPoints.md}) { + display: ${({ $isSearching }) => !$isSearching && 'block'}; + } +`; diff --git a/frontend/src/features/search/components/SearchPreviewSheet.tsx b/frontend/src/features/search/components/SearchPreviewSheet.tsx new file mode 100644 index 000000000..4c515738a --- /dev/null +++ b/frontend/src/features/search/components/SearchPreviewSheet.tsx @@ -0,0 +1,148 @@ +import { useNavigate } from 'react-router-dom'; +import styled from 'styled-components'; +import Thumbnail from '@/features/songs/components/Thumbnail'; +import Flex from '@/shared/components/Flex/Flex'; +import ROUTE_PATH from '@/shared/constants/path'; +import type { SingerSearchPreview } from '../types/search'; + +interface ResultSheetProps { + result: SingerSearchPreview[]; + endSearch: () => void; +} + +const SearchPreviewSheet = ({ result, endSearch }: ResultSheetProps) => { + const navigate = useNavigate(); + + const hasResult = result.length > 0; + + const goToSingerDetailPage = (id: number) => { + endSearch(); + navigate(`${ROUTE_PATH.SINGER_DETAIL}/${id}`); + }; + + return ( + <> + + + + 아티스트 + + + {result.map(({ id, singer, profileImageUrl }) => ( + + goToSingerDetailPage(id)} + aria-label={`${singer} 상세 페이지 바로가기`} + $gap={16} + $align="center" + > + + {singer} + + + ))} + + {!hasResult && 검색 결과가 없습니다} + + + ); +}; + +export default SearchPreviewSheet; + +export const Backdrop = styled.div` + position: fixed; + top: 80px; + left: 0; + + width: 100%; + height: 100%; + + background-color: rgba(0, 0, 0, 0.5); +`; + +const SheetContainer = styled.section` + position: fixed; + z-index: 2000; + top: 70px; + right: 12.33%; + + overflow-y: scroll; + + width: 340px; + height: auto; + max-height: 320px; + padding: 0 16px 16px 16px; + + color: white; + + background-color: #121212; + border-radius: 8px; + box-shadow: 0 0 10px #ffffff49; + + @media (max-width: ${({ theme }) => theme.breakPoints.xxl}) { + right: 8.33%; + } + + @media (max-width: ${({ theme }) => theme.breakPoints.md}) { + right: 0; + width: 100%; + min-height: 100%; + } + + @media (max-width: ${({ theme }) => theme.breakPoints.xs}) { + top: 60px; + } + + @media (max-width: ${({ theme }) => theme.breakPoints.xxs}) { + top: 50px; + } +`; + +const FlexSheetTitle = styled(Flex)` + position: sticky; + top: 0; + left: 0; + + height: 50px; + + font-size: 18px; + font-weight: 700; + + background-color: #121212; +`; + +const FlexPreviewItemList = styled(Flex)``; + +const FlexPreviewItem = styled(Flex)` + cursor: pointer; + + width: 100%; + height: 66px; + padding: 8px; + + background-color: ${({ theme: { color } }) => color.black400}; + border-radius: 4px; + + &:hover { + background-color: ${({ theme: { color } }) => color.secondary}; + } +`; + +const Singer = styled.p` + font-weight: 700; + letter-spacing: 1px; +`; +const DefaultMessage = styled.p` + font-size: 14px; +`; + +const FlexGoToDetail = styled(Flex)` + width: 100%; +`; diff --git a/frontend/src/features/search/hooks/useSearchBar.ts b/frontend/src/features/search/hooks/useSearchBar.ts new file mode 100644 index 000000000..28062f14e --- /dev/null +++ b/frontend/src/features/search/hooks/useSearchBar.ts @@ -0,0 +1,107 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import ROUTE_PATH from '@/shared/constants/path'; +import useDebounceEffect from '@/shared/hooks/useDebounceEffect'; +import useFetch from '@/shared/hooks/useFetch'; +import { getSingerSearchPreview } from '../remotes/search'; + +const useSearchBar = () => { + const [isSearching, setIsSearching] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const inputRef = useRef(null); + const navigate = useNavigate(); + + const { data: singerSearchPreview, fetchData: fetchSingerSearchPreview } = useFetch( + () => getSingerSearchPreview(searchQuery), + false + ); + + useDebounceEffect(fetchSingerSearchPreview, searchQuery, 300); + + const search: React.FormEventHandler = useCallback( + (e) => { + e.preventDefault(); + + if (searchQuery.length === 0) return; + + setIsSearching(false); + navigate(`${ROUTE_PATH.SEARCH_RESULT}?name=${searchQuery}`); + }, + [searchQuery, navigate] + ); + + const startSearch = useCallback(() => { + setIsSearching(true); + }, []); + + const endSearchOnBlur: React.FocusEventHandler = useCallback( + ({ relatedTarget }) => { + if (relatedTarget?.id === 'search-preview-sheet') return; + if (relatedTarget?.id === 'query-reset-button') return; + if (relatedTarget?.id === 'search-button') return; + + setIsSearching(false); + }, + [] + ); + + const endSearch = useCallback(() => { + setIsSearching(false); + }, []); + + const changeQuery: React.ChangeEventHandler = useCallback( + ({ currentTarget }) => { + setSearchQuery(currentTarget.value); + }, + [] + ); + + const resetQuery: React.MouseEventHandler = useCallback(() => { + setSearchQuery(''); + inputRef.current?.focus(); + }, []); + + const endSearchByEsc = useCallback( + ({ key }: KeyboardEvent) => { + if (key === 'Escape') { + endSearch(); + } + }, + [endSearch] + ); + + useEffect(() => { + if (isSearching) { + inputRef.current?.focus(); + } else { + inputRef.current?.blur(); + } + }, [isSearching]); + + useEffect(() => { + if (isSearching) { + document.body.style.overflow = 'hidden'; + document.addEventListener('keydown', endSearchByEsc); + } + + return () => { + document.body.style.overflow = 'auto'; + document.addEventListener('keydown', endSearchByEsc); + }; + }, [isSearching, endSearchByEsc]); + + return { + isSearching, + searchQuery, + inputRef, + singerSearchPreview, + startSearch, + endSearchOnBlur, + endSearch, + changeQuery, + resetQuery, + search, + }; +}; + +export default useSearchBar; diff --git a/frontend/src/features/search/remotes/search.ts b/frontend/src/features/search/remotes/search.ts new file mode 100644 index 000000000..486a71ec8 --- /dev/null +++ b/frontend/src/features/search/remotes/search.ts @@ -0,0 +1,16 @@ +import fetcher from '@/shared/remotes'; +import type { SingerSearchPreview, SingerSearchResult } from '../types/search'; + +export const getSingerSearchPreview = async (query: string): Promise => { + const encodedQuery = encodeURIComponent(query); + return await fetcher(`/singers?name=${encodedQuery}&search=singer`, 'GET'); +}; + +export const getSingerSearch = async (query: string): Promise => { + const encodedQuery = encodeURIComponent(query); + return await fetcher(`/singers?name=${encodedQuery}&search=singer&search=song`, 'GET'); +}; + +export const getSingerDetail = async (singerId: number): Promise => { + return await fetcher(`/singers/${singerId}`, 'GET'); +}; diff --git a/frontend/src/features/search/types/search.ts b/frontend/src/features/search/types/search.ts new file mode 100644 index 000000000..133dad1b7 --- /dev/null +++ b/frontend/src/features/search/types/search.ts @@ -0,0 +1,20 @@ +interface SingersSong { + id: number; + title: string; + albumCoverUrl: string; + videoLength: number; +} + +export interface SingerSearchPreview { + id: number; + singer: string; + profileImageUrl: string; +} + +export interface SingerSearchResult { + id: number; + singer: string; + profileImageUrl: string; + totalSongCount: number; + songs: SingersSong[]; +} diff --git a/frontend/src/features/songs/components/Thumbnail.tsx b/frontend/src/features/songs/components/Thumbnail.tsx index 9a90e106a..28082b28c 100644 --- a/frontend/src/features/songs/components/Thumbnail.tsx +++ b/frontend/src/features/songs/components/Thumbnail.tsx @@ -28,6 +28,11 @@ const Wrapper = styled.div<{ $size: Size; $borderRadius: number }>` `; const SIZE_VARIANTS = { + sm: css` + width: 50px; + height: 50px; + `, + md: css` width: 60px; height: 60px; diff --git a/frontend/src/mocks/browser.ts b/frontend/src/mocks/browser.ts index 9d862b0b3..bd0b93421 100644 --- a/frontend/src/mocks/browser.ts +++ b/frontend/src/mocks/browser.ts @@ -1,5 +1,4 @@ import { setupWorker } from 'msw'; -import { memberHandlers } from './handlers/memberHandlers'; -import { songsHandlers } from './handlers/songsHandlers'; +import handlers from './handlers'; -export const worker = setupWorker(...songsHandlers, ...memberHandlers); +export const worker = setupWorker(...handlers); diff --git a/frontend/src/mocks/fixtures/searchedSingerPreview.json b/frontend/src/mocks/fixtures/searchedSingerPreview.json new file mode 100644 index 000000000..ec9654fad --- /dev/null +++ b/frontend/src/mocks/fixtures/searchedSingerPreview.json @@ -0,0 +1,17 @@ +[ + { + "id": 1, + "singer": "악동뮤지션", + "profileImageUrl": "https://cdnimg.melon.co.kr/cm2/artistcrop/images/007/12/452/712452_20230821182358_500.jpg?f9ab949ec336274795e07b2719addab6/melon/resize/416/quality/80/optimize" + }, + { + "id": 2, + "singer": "악동", + "profileImageUrl": "https://cdnimg.melon.co.kr/cm2/artistcrop/images/007/12/452/712452_20230821182358_500.jpg?f9ab949ec336274795e07b2719addab6/melon/resize/416/quality/80/optimize" + }, + { + "id": 3, + "singer": "뮤지션", + "profileImageUrl": "https://cdnimg.melon.co.kr/cm2/artistcrop/images/007/12/452/712452_20230821182358_500.jpg?f9ab949ec336274795e07b2719addab6/melon/resize/416/quality/80/optimize" + } +] diff --git a/frontend/src/mocks/fixtures/searchedSingers.json b/frontend/src/mocks/fixtures/searchedSingers.json new file mode 100644 index 000000000..73583d623 --- /dev/null +++ b/frontend/src/mocks/fixtures/searchedSingers.json @@ -0,0 +1,80 @@ +[ + { + "id": 1, + "singer": "악동뮤지션", + "profileImageUrl": "https://cdnimg.melon.co.kr/cm2/artistcrop/images/007/12/452/712452_20230821182358_500.jpg?f9ab949ec336274795e07b2719addab6/melon/resize/416/quality/80/optimize", + "totalSongCount": 6, + "songs": [ + { + "id": 1, + "title": "계란찜의 꿈", + "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/artistcrop/images/007/12/452/712452_20230821182358_500.jpg?f9ab949ec336274795e07b2719addab6/melon/resize/416/quality/80/optimize", + "videoLength": 111 + }, + { + "id": 2, + "title": "수란의 꿈", + "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/artistcrop/images/007/12/452/712452_20230821182358_500.jpg?f9ab949ec336274795e07b2719addab6/melon/resize/416/quality/80/optimize", + "videoLength": 222 + }, + { + "id": 3, + "title": "서니사이드업의 서니사이드업의 서니사이드업의 서니사이드업의 서니사이드업의 서니사이드업의 서니사이드업의 서니사이드업의 꿈", + "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/artistcrop/images/007/12/452/712452_20230821182358_500.jpg?f9ab949ec336274795e07b2719addab6/melon/resize/416/quality/80/optimize", + "videoLength": 333 + } + ] + }, + { + "id": 2, + "singer": "악동", + "profileImageUrl": "https://cdnimg.melon.co.kr/cm2/artistcrop/images/007/12/452/712452_20230821182358_500.jpg?f9ab949ec336274795e07b2719addab6/melon/resize/416/quality/80/optimize", + "totalSongCount": 12, + "songs": [ + { + "id": 4, + "title": "계란찜의 꿈", + "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/artistcrop/images/007/12/452/712452_20230821182358_500.jpg?f9ab949ec336274795e07b2719addab6/melon/resize/416/quality/80/optimize", + "videoLength": 111 + }, + { + "id": 5, + "title": "수란의 꿈", + "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/artistcrop/images/007/12/452/712452_20230821182358_500.jpg?f9ab949ec336274795e07b2719addab6/melon/resize/416/quality/80/optimize", + "videoLength": 222 + }, + { + "id": 6, + "title": "서니사이드업의 서니사이드업의 서니사이드업의 서니사이드업의 서니사이드업의 서니사이드업의 서니사이드업의 서니사이드업의 꿈", + "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/artistcrop/images/007/12/452/712452_20230821182358_500.jpg?f9ab949ec336274795e07b2719addab6/melon/resize/416/quality/80/optimize", + "videoLength": 333 + } + ] + }, + { + "id": 3, + "singer": "뮤지션", + "profileImageUrl": "https://cdnimg.melon.co.kr/cm2/artistcrop/images/007/12/452/712452_20230821182358_500.jpg?f9ab949ec336274795e07b2719addab6/melon/resize/416/quality/80/optimize", + "totalSongCount": 18, + "songs": [ + { + "id": 7, + "title": "계란찜의 꿈", + "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/artistcrop/images/007/12/452/712452_20230821182358_500.jpg?f9ab949ec336274795e07b2719addab6/melon/resize/416/quality/80/optimize", + "videoLength": 111 + }, + { + "id": 8, + "title": "수란의 꿈", + "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/artistcrop/images/007/12/452/712452_20230821182358_500.jpg?f9ab949ec336274795e07b2719addab6/melon/resize/416/quality/80/optimize", + "videoLength": 222 + }, + { + "id": 9, + "title": "서니사이드업의 서니사이드업의 서니사이드업의 서니사이드업의 서니사이드업의 서니사이드업의 서니사이드업의 서니사이드업의 꿈", + "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/artistcrop/images/007/12/452/712452_20230821182358_500.jpg?f9ab949ec336274795e07b2719addab6/melon/resize/416/quality/80/optimize", + "videoLength": 333 + } + ] + } +] diff --git a/frontend/src/mocks/handlers/index.ts b/frontend/src/mocks/handlers/index.ts new file mode 100644 index 000000000..4189b9d25 --- /dev/null +++ b/frontend/src/mocks/handlers/index.ts @@ -0,0 +1,7 @@ +import memberHandlers from './memberHandlers'; +import searchHandlers from './searchHandlers'; +import songsHandlers from './songsHandlers'; + +const handlers = [...memberHandlers, ...searchHandlers, ...songsHandlers]; + +export default handlers; diff --git a/frontend/src/mocks/handlers/memberHandlers.ts b/frontend/src/mocks/handlers/memberHandlers.ts index 4cf177928..c74897856 100644 --- a/frontend/src/mocks/handlers/memberHandlers.ts +++ b/frontend/src/mocks/handlers/memberHandlers.ts @@ -2,7 +2,7 @@ import { rest } from 'msw'; const { BASE_URL } = process.env; -export const memberHandlers = [ +const memberHandlers = [ rest.get(`${BASE_URL}/my-page`, (req, res, ctx) => { return res( ctx.json([ @@ -72,3 +72,5 @@ export const memberHandlers = [ ); }), ]; + +export default memberHandlers; diff --git a/frontend/src/mocks/handlers/searchHandlers.ts b/frontend/src/mocks/handlers/searchHandlers.ts new file mode 100644 index 000000000..56d1fdf4b --- /dev/null +++ b/frontend/src/mocks/handlers/searchHandlers.ts @@ -0,0 +1,47 @@ +import { rest } from 'msw'; +import searchedSingerPreview from '@/mocks/fixtures/searchedSingerPreview.json'; +import searchedSingers from '@/mocks/fixtures/searchedSingers.json'; + +const { BASE_URL } = process.env; + +const searchHandlers = [ + rest.get(`${BASE_URL}/singers`, (req, res, ctx) => { + const query = req.url.searchParams.get('name') ?? ''; + const [singer, song] = req.url.searchParams.getAll('search'); + const testQueries = ['악동뮤지션', '악동', '뮤지션']; + + const isPreviewRequest = singer !== undefined && song === undefined; + const isSearchRequest = song !== undefined; + + const isInTestQueries = testQueries.some( + (testQuery) => encodeURIComponent(testQuery) === encodeURIComponent(query) + ); + + if (isPreviewRequest && isInTestQueries) { + return res(ctx.status(200), ctx.json(searchedSingerPreview)); + } + + if (isSearchRequest && isInTestQueries) { + return res(ctx.status(200), ctx.json(searchedSingers)); + } + + if (!isInTestQueries) { + return res(ctx.status(200), ctx.json([])); + } + }), + + rest.get(`${BASE_URL}/singers/:singerId`, (req, res, ctx) => { + const { singerId } = req.params; + + const numberSingerId = Number(singerId as string); + const searchedSinger = searchedSingers[numberSingerId]; + + if (searchedSinger !== undefined) { + return res(ctx.status(200), ctx.json(searchedSinger)); + } + + return res(ctx.status(400), ctx.json({})); + }), +]; + +export default searchHandlers; diff --git a/frontend/src/mocks/handlers/songsHandlers.ts b/frontend/src/mocks/handlers/songsHandlers.ts index 882f13936..769762387 100644 --- a/frontend/src/mocks/handlers/songsHandlers.ts +++ b/frontend/src/mocks/handlers/songsHandlers.ts @@ -9,7 +9,7 @@ import type { KillingPartPostRequest } from '@/shared/types/killingPart'; const { BASE_URL } = process.env; -export const songsHandlers = [ +const songsHandlers = [ rest.get(`${BASE_URL}/songs/high-liked`, (req, res, ctx) => { // const genre = req.url.searchParams.get('genre') return res(ctx.status(200), ctx.json(popularSongs)); @@ -58,3 +58,5 @@ export const songsHandlers = [ return res(ctx.status(200), ctx.json(votingSongs)); }), ]; + +export default songsHandlers; diff --git a/frontend/src/pages/SearchResultPage.tsx b/frontend/src/pages/SearchResultPage.tsx new file mode 100644 index 000000000..4a9084166 --- /dev/null +++ b/frontend/src/pages/SearchResultPage.tsx @@ -0,0 +1,9 @@ +import useValidSearchParams from '@/shared/hooks/useValidSearchParams'; + +const SearchResultPage = () => { + const { name } = useValidSearchParams('name'); + + return
{name}
; +}; + +export default SearchResultPage; diff --git a/frontend/src/pages/SingerDetailPage.tsx b/frontend/src/pages/SingerDetailPage.tsx new file mode 100644 index 000000000..3c977f7e0 --- /dev/null +++ b/frontend/src/pages/SingerDetailPage.tsx @@ -0,0 +1,9 @@ +import useValidParams from '@/shared/hooks/useValidParams'; + +const SingerDetailPage = () => { + const { singerId } = useValidParams(); + + return
{singerId}
; +}; + +export default SingerDetailPage; diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 99e9b8af7..101b076c2 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -6,6 +6,8 @@ import LoginPage from './pages/LoginPage'; import MainPage from './pages/MainPage'; import MyPage from './pages/MyPage'; import PartCollectingPage from './pages/PartCollectingPage'; +import SearchResultPage from './pages/SearchResultPage'; +import SingerDetailPage from './pages/SingerDetailPage'; import SongDetailListPage from './pages/SongDetailListPage'; import AuthLayout from './shared/components/Layout/AuthLayout'; import Layout from './shared/components/Layout/Layout'; @@ -52,6 +54,14 @@ const router = createBrowserRouter([ ), }, + { + path: `${ROUTE_PATH.SEARCH_RESULT}`, + element: , + }, + { + path: `${ROUTE_PATH.SINGER_DETAIL}/:singerId`, + element: , + }, ], }, { diff --git a/frontend/src/shared/components/Flex/Flex.tsx b/frontend/src/shared/components/Flex/Flex.tsx index 83818cf7c..5904fc8ba 100644 --- a/frontend/src/shared/components/Flex/Flex.tsx +++ b/frontend/src/shared/components/Flex/Flex.tsx @@ -57,7 +57,7 @@ export default Flex; const flexCss = (flexBox?: FlexBox) => { if (!flexBox) return; - const { $align, $direction, $gap, $justify, $wrap } = flexBox; + const { $align, $direction, $gap, $justify, $wrap, $css } = flexBox; return css` flex-direction: ${$direction}; @@ -65,5 +65,6 @@ const flexCss = (flexBox?: FlexBox) => { gap: ${$gap && `${$gap}px`}; align-items: ${$align}; justify-content: ${$justify}; + ${$css} `; }; diff --git a/frontend/src/shared/components/Layout/Header.tsx b/frontend/src/shared/components/Layout/Header.tsx index e3201c7f6..57a884a95 100644 --- a/frontend/src/shared/components/Layout/Header.tsx +++ b/frontend/src/shared/components/Layout/Header.tsx @@ -3,8 +3,10 @@ import { styled } from 'styled-components'; import shookshook from '@/assets/icon/shookshook.svg'; import logo from '@/assets/image/logo.png'; import { useAuthContext } from '@/features/auth/components/AuthProvider'; +import SearchBar from '@/features/search/components/SearchBar'; import ROUTE_PATH from '@/shared/constants/path'; import Avatar from '../Avatar'; +import Flex from '../Flex/Flex'; const Header = () => { const { user } = useAuthContext(); @@ -14,15 +16,18 @@ const Header = () => {