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 = () => {
- {user ? (
-
-
-
- ) : (
-
- 로그인
-
- )}
+
+
+ {user ? (
+
+
+
+ ) : (
+
+ 로그인
+
+ )}
+
);
};
@@ -32,7 +37,8 @@ export default Header;
const Container = styled.header`
position: fixed;
z-index: 1000;
- top: 0px;
+ top: 0;
+ left: 0;
display: flex;
align-items: center;
@@ -67,9 +73,18 @@ const Logo = styled.img`
width: 180px;
@media (max-width: ${({ theme }) => theme.breakPoints.md}) {
+ position: absolute;
+ top: 50%;
+ left: 4.16%;
+ transform: translate(0, -50%);
+
width: 140px;
}
+ @media (max-width: ${({ theme }) => theme.breakPoints.xs}) {
+ left: 16px;
+ }
+
@media (max-width: ${({ theme }) => theme.breakPoints.xxs}) {
width: 120px;
}
diff --git a/frontend/src/shared/constants/path.ts b/frontend/src/shared/constants/path.ts
index b885585c0..cf70a9a42 100644
--- a/frontend/src/shared/constants/path.ts
+++ b/frontend/src/shared/constants/path.ts
@@ -7,6 +7,8 @@ const ROUTE_PATH = {
KAKAO_REDIRECT: 'kakao/redirect',
MY_PAGE: 'my-page',
EDIT_PROFILE: 'my-page/edit',
+ SEARCH_RESULT: 'search',
+ SINGER_DETAIL: 'singer',
} as const;
export default ROUTE_PATH;
diff --git a/frontend/src/shared/hooks/useValidSearchParams.ts b/frontend/src/shared/hooks/useValidSearchParams.ts
new file mode 100644
index 000000000..21f1d3269
--- /dev/null
+++ b/frontend/src/shared/hooks/useValidSearchParams.ts
@@ -0,0 +1,18 @@
+import { useSearchParams } from 'react-router-dom';
+
+const useValidSearchParams = (...params: string[]) => {
+ const [searchParams] = useSearchParams();
+
+ const validParams: Record = {};
+
+ params.forEach((param) => {
+ const validParam = searchParams.get(param);
+ if (validParam === null) throw new Error('Invalid search parameters');
+
+ validParams[param] = validParam;
+ });
+
+ return validParams;
+};
+
+export default useValidSearchParams;