From bab208bb7e766165e552047e93846451652a98a0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=EC=9D=B4=EB=8F=84=ED=98=84?=
<77152650+Creative-Lee@users.noreply.github.com>
Date: Thu, 19 Oct 2023 22:50:04 +0900
Subject: [PATCH] =?UTF-8?q?Feat/#513,=20#523=20=20=EA=B2=80=EC=83=89?=
=?UTF-8?q?=EC=99=84=EB=A3=8C,=20=EA=B0=80=EC=88=98=20=EC=83=81=EC=84=B8?=
=?UTF-8?q?=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC=ED=98=84=20=EB=B0=8F?=
=?UTF-8?q?=20=EB=A9=94=EC=9D=B8=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=BC=80?=
=?UTF-8?q?=EB=9F=AC=EC=85=80=20=EC=A0=95=EC=B1=85=20=EB=B3=80=EA=B2=BD=20?=
=?UTF-8?q?=EB=B0=98=EC=98=81=20(#525)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat: 가수 상세 페이지 구현
* refactor: 핸들러 로직 수정
* design: 반응형 디자인 추가
* chore: type 파일 suffix 추가
* refactor: 가수 디테일과 검색결과 type 분리
* design: 미디어 쿼리 호버 적용
* chore: type 파일 이동
* refactor: SingersSong type 추가
* refactor: 컴포넌트 분리
* test: 스토리 추가
* design: 분리된 컴포넌트 색상 추가
* refactor: Spacing 컴포넌트 반응형 디자인 props 추가
* feat: 노래 클릭 시 듣기페이지 이동 기능 구현
* refactor: 컴포넌트 분리
리스트 컴포넌트 분리
* refactor: 하위 컴포넌트가 title을 가지도록 변경
* feat: 검색 결과 페이지 구현
* chore: 노래 검색결과 픽스쳐 데이터 수정
* feat: 배너 클릭 시 가수페이지 이동 기능 구현
* chore: 사용하지 않는 import 삭제 및 픽스쳐 데이터 삭제
* feat: 배너 호버 시 아이콘 디테일 추가
* design: 아이콘 사이즈 축소
* chore: 사용하지 않는 훅 삭제
* feat: 메인 케러셀 최근 추가된 노래 리스트로 대체
* feat: 메인 케러셀 최근 추가된 노래 리스트로 대체
* feat: 최근 들은 노래 핸들러 및 fixture 구현
* design: 글로벌 스타일 수정
스토리북에서 li요소 단일 랜더 시 ::marker 표시 이슈로 li list styled none 추가
* test: singer관련 스토리 추가
* design: title color 추가
* test: 스토리 title prefix 추가로 디렉터리 구분
* design: 기본 배경색 지정
* fix: lint 에러 해결
* refactor: spacing 컴포넌트 변수명 변경으로 가독성 개선
* refactor: null 명시
* chore: singer 리모트 함수 파일 위치 변경
* refactor: api 명세 변경으로 의미 개선
검색 api 엔드포인트 변경
singer -> search / 검색 의미 강조하고자 함
name -> keyword / 범용적인 검색어의 의미를 주고자 함
search -> type / 검색 타입의 의미를 주고자함
* refactor: api 명세 변경 msw 반영 및 singer 핸들러 분리
* refactor: 함수 불리언 화 방식 변경
* design: 페이지 별 title을 다르게 하여 디자인 구분 되로록 개선
* refactor: params가 변경되면 데이터를 refetch하도록 변경
---
frontend/src/assets/icon/right-long-arrow.svg | 3 +
.../search/components/SearchBar.stories.tsx | 2 +-
.../components/SearchPreviewSheet.stories.tsx | 15 +++
.../search/components/SearchPreviewSheet.tsx | 2 +-
.../src/features/search/remotes/search.ts | 13 +-
.../src/features/search/types/search.type.ts | 5 +
.../components/SingerBanner.stories.tsx | 15 +++
.../singer/components/SingerBanner.tsx | 107 ++++++++++++++++
.../components/SingerSongItem.stories.tsx | 17 +++
.../singer/components/SingerSongItem.tsx | 116 ++++++++++++++++++
.../components/SingerSongList.stories.tsx | 17 +++
.../singer/components/SingerSongList.tsx | 44 +++++++
.../src/features/singer/remotes/singer.ts | 6 +
.../search.ts => singer/types/singer.type.ts} | 11 +-
.../songs/components/CarouselItem.tsx | 35 ++----
.../components/CollectionCarousel.stories.tsx | 2 +-
.../components/KillingPartTrack.stories.tsx | 2 +-
.../KillingPartTrackList.stories.tsx | 3 +-
.../songs/components/SongItem.stories.tsx | 1 +
frontend/src/features/songs/remotes/song.ts | 9 +-
.../songs/remotes/useGetSongDetail.ts | 14 ---
frontend/src/mocks/fixtures/recentSongs.json | 44 +++++++
.../src/mocks/fixtures/searchedSingers.json | 33 +++--
frontend/src/mocks/fixtures/songEntries.json | 8 +-
frontend/src/mocks/handlers/index.ts | 3 +-
frontend/src/mocks/handlers/searchHandlers.ts | 20 +--
frontend/src/mocks/handlers/singerHandlers.ts | 22 ++++
frontend/src/mocks/handlers/songsHandlers.ts | 10 +-
frontend/src/pages/MainPage.tsx | 31 ++---
frontend/src/pages/SearchResultPage.tsx | 75 ++++++++++-
frontend/src/pages/SingerDetailPage.tsx | 47 ++++++-
.../BottomSheet/BottomSheet.stories.tsx | 1 +
.../shared/components/Modal/Modal.stories.tsx | 1 +
frontend/src/shared/components/Spacing.tsx | 58 ++++++++-
.../shared/components/Toast/Toast.stories.tsx | 2 +-
.../ToggleSwitch/ToggleSwitch.stories.tsx | 2 +-
frontend/src/shared/styles/GlobalStyles.ts | 2 +-
frontend/src/shared/types/song.ts | 8 ++
38 files changed, 673 insertions(+), 133 deletions(-)
create mode 100644 frontend/src/assets/icon/right-long-arrow.svg
create mode 100644 frontend/src/features/search/components/SearchPreviewSheet.stories.tsx
create mode 100644 frontend/src/features/search/types/search.type.ts
create mode 100644 frontend/src/features/singer/components/SingerBanner.stories.tsx
create mode 100644 frontend/src/features/singer/components/SingerBanner.tsx
create mode 100644 frontend/src/features/singer/components/SingerSongItem.stories.tsx
create mode 100644 frontend/src/features/singer/components/SingerSongItem.tsx
create mode 100644 frontend/src/features/singer/components/SingerSongList.stories.tsx
create mode 100644 frontend/src/features/singer/components/SingerSongList.tsx
create mode 100644 frontend/src/features/singer/remotes/singer.ts
rename frontend/src/features/{search/types/search.ts => singer/types/singer.type.ts} (59%)
delete mode 100644 frontend/src/features/songs/remotes/useGetSongDetail.ts
create mode 100644 frontend/src/mocks/fixtures/recentSongs.json
create mode 100644 frontend/src/mocks/handlers/singerHandlers.ts
diff --git a/frontend/src/assets/icon/right-long-arrow.svg b/frontend/src/assets/icon/right-long-arrow.svg
new file mode 100644
index 000000000..3f7e2f49a
--- /dev/null
+++ b/frontend/src/assets/icon/right-long-arrow.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/features/search/components/SearchBar.stories.tsx b/frontend/src/features/search/components/SearchBar.stories.tsx
index 3b22a0237..bbd27eada 100644
--- a/frontend/src/features/search/components/SearchBar.stories.tsx
+++ b/frontend/src/features/search/components/SearchBar.stories.tsx
@@ -4,7 +4,7 @@ import type { Meta, StoryObj } from '@storybook/react';
const meta = {
component: SearchBar,
- title: 'SearchBar',
+ title: 'search/SearchBar',
} satisfies Meta;
export default meta;
diff --git a/frontend/src/features/search/components/SearchPreviewSheet.stories.tsx b/frontend/src/features/search/components/SearchPreviewSheet.stories.tsx
new file mode 100644
index 000000000..e2c2951e2
--- /dev/null
+++ b/frontend/src/features/search/components/SearchPreviewSheet.stories.tsx
@@ -0,0 +1,15 @@
+import searchedSingerPreview from '@/mocks/fixtures/searchedSingerPreview.json';
+import SearchPreviewSheet from './SearchPreviewSheet';
+import type { Meta, StoryObj } from '@storybook/react';
+
+const meta = {
+ component: SearchPreviewSheet,
+ title: 'search/SearchPreviewSheet',
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () => {}} />,
+};
diff --git a/frontend/src/features/search/components/SearchPreviewSheet.tsx b/frontend/src/features/search/components/SearchPreviewSheet.tsx
index 4c515738a..fca5deba5 100644
--- a/frontend/src/features/search/components/SearchPreviewSheet.tsx
+++ b/frontend/src/features/search/components/SearchPreviewSheet.tsx
@@ -3,7 +3,7 @@ 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';
+import type { SingerSearchPreview } from '../types/search.type';
interface ResultSheetProps {
result: SingerSearchPreview[];
diff --git a/frontend/src/features/search/remotes/search.ts b/frontend/src/features/search/remotes/search.ts
index 486a71ec8..86208433c 100644
--- a/frontend/src/features/search/remotes/search.ts
+++ b/frontend/src/features/search/remotes/search.ts
@@ -1,16 +1,13 @@
import fetcher from '@/shared/remotes';
-import type { SingerSearchPreview, SingerSearchResult } from '../types/search';
+import type { SingerDetail } from '../../singer/types/singer.type';
+import type { SingerSearchPreview } from '../types/search.type';
export const getSingerSearchPreview = async (query: string): Promise => {
const encodedQuery = encodeURIComponent(query);
- return await fetcher(`/singers?name=${encodedQuery}&search=singer`, 'GET');
+ return await fetcher(`/search?keyword=${encodedQuery}&type=singer`, 'GET');
};
-export const getSingerSearch = async (query: string): Promise => {
+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');
+ return await fetcher(`/search?keyword=${encodedQuery}&type=singer&type=song`, 'GET');
};
diff --git a/frontend/src/features/search/types/search.type.ts b/frontend/src/features/search/types/search.type.ts
new file mode 100644
index 000000000..cf045db54
--- /dev/null
+++ b/frontend/src/features/search/types/search.type.ts
@@ -0,0 +1,5 @@
+export interface SingerSearchPreview {
+ id: number;
+ singer: string;
+ profileImageUrl: string;
+}
diff --git a/frontend/src/features/singer/components/SingerBanner.stories.tsx b/frontend/src/features/singer/components/SingerBanner.stories.tsx
new file mode 100644
index 000000000..0d826adf0
--- /dev/null
+++ b/frontend/src/features/singer/components/SingerBanner.stories.tsx
@@ -0,0 +1,15 @@
+import searchedSingers from '@/mocks/fixtures/searchedSingers.json';
+import SingerBanner from './SingerBanner';
+import type { Meta, StoryObj } from '@storybook/react';
+
+const meta = {
+ component: SingerBanner,
+ title: 'singer/SingerBanner',
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () => ,
+};
diff --git a/frontend/src/features/singer/components/SingerBanner.tsx b/frontend/src/features/singer/components/SingerBanner.tsx
new file mode 100644
index 000000000..fd30fe56d
--- /dev/null
+++ b/frontend/src/features/singer/components/SingerBanner.tsx
@@ -0,0 +1,107 @@
+import styled from 'styled-components';
+import rightArrow from '@/assets/icon/right-long-arrow.svg';
+import Flex from '@/shared/components/Flex/Flex';
+import type { SingerDetail } from '@/features/singer/types/singer.type';
+
+interface SingerBannerProps
+ extends Pick {
+ onClick?: () => void;
+}
+
+const SingerBanner = ({ profileImageUrl, singer, totalSongCount, onClick }: SingerBannerProps) => {
+ const clickable = typeof onClick === 'function';
+
+ return (
+
+ 아티스트
+
+
+
+ {singer}
+ {`등록된 노래 ${totalSongCount}개`}
+
+
+
+ );
+};
+
+export default SingerBanner;
+
+const Container = styled.div``;
+
+const SingerInfoContainer = styled(Flex)<{ $clickable: boolean }>`
+ cursor: ${({ $clickable }) => ($clickable ? 'pointer' : 'default')};
+
+ position: relative;
+
+ width: 370px;
+ height: 240px;
+ padding: 20px;
+
+ color: ${({ theme: { color } }) => color.white};
+
+ background-color: ${({ theme: { color } }) => color.black500};
+ border-radius: 8px;
+
+ transition: background-color 0.3s ease;
+
+ @media (hover: hover) {
+ &:hover {
+ background-color: ${({ $clickable, theme: { color } }) =>
+ $clickable ? color.secondary : ''};
+
+ &::after {
+ content: '';
+
+ position: absolute;
+ right: 10px;
+ bottom: 4px;
+
+ width: 30px;
+ height: 30px;
+
+ background: ${({ $clickable }) =>
+ $clickable ? `no-repeat center url(${rightArrow})` : ''};
+ }
+ }
+ }
+
+ @media (max-width: ${({ theme }) => theme.breakPoints.md}) {
+ width: 100%;
+ height: 180px;
+ }
+`;
+
+const ProfileImage = styled.img`
+ width: 100px;
+ height: 100px;
+ border-radius: 50%;
+`;
+
+const Name = styled.div`
+ font-size: 36px;
+ font-weight: 700;
+
+ @media (max-width: ${({ theme }) => theme.breakPoints.md}) {
+ font-size: 20px;
+ }
+`;
+
+const SongCount = styled.p`
+ font-size: 18px;
+
+ @media (max-width: ${({ theme }) => theme.breakPoints.md}) {
+ font-size: 14px;
+ }
+`;
+
+const Title = styled.h1`
+ margin-bottom: 18px;
+ font-size: 28px;
+ font-weight: 700;
+ color: ${({ theme: { color } }) => color.white};
+
+ @media (max-width: ${({ theme }) => theme.breakPoints.md}) {
+ font-size: 24px;
+ }
+`;
diff --git a/frontend/src/features/singer/components/SingerSongItem.stories.tsx b/frontend/src/features/singer/components/SingerSongItem.stories.tsx
new file mode 100644
index 000000000..62b68aeed
--- /dev/null
+++ b/frontend/src/features/singer/components/SingerSongItem.stories.tsx
@@ -0,0 +1,17 @@
+import searchedSingers from '@/mocks/fixtures/searchedSingers.json';
+import SingerSongItem from './SingerSongItem';
+import type { Meta, StoryObj } from '@storybook/react';
+
+const song = searchedSingers[0].songs[0];
+
+const meta = {
+ component: SingerSongItem,
+ title: 'singer/SingerSongItem',
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () => ,
+};
diff --git a/frontend/src/features/singer/components/SingerSongItem.tsx b/frontend/src/features/singer/components/SingerSongItem.tsx
new file mode 100644
index 000000000..d9d24beaa
--- /dev/null
+++ b/frontend/src/features/singer/components/SingerSongItem.tsx
@@ -0,0 +1,116 @@
+import { Link } from 'react-router-dom';
+import styled from 'styled-components';
+import Flex from '@/shared/components/Flex/Flex';
+import ROUTE_PATH from '@/shared/constants/path';
+import { toMinSecText } from '@/shared/utils/convertTime';
+import type { SingersSong } from '@/features/singer/types/singer.type';
+
+interface SingerSongItemProps extends SingersSong {}
+
+const SingerSongItem = ({ id, singer, albumCoverUrl, title, videoLength }: SingerSongItemProps) => {
+ return (
+
+
+
+
+
+
+ {title}
+ {singer}
+
+
+ {toMinSecText(videoLength)}
+
+
+
+ );
+};
+
+export default SingerSongItem;
+
+const FlexLink = styled(Link)`
+ display: flex;
+ gap: 16px;
+ justify-content: center;
+ width: 100%;
+
+ @media (max-width: ${({ theme }) => theme.breakPoints.xs}) {
+ align-items: center;
+ }
+`;
+
+const SongListItem = styled.li`
+ width: 100%;
+ padding: 8px;
+
+ color: ${({ theme: { color } }) => color.white};
+
+ background-color: ${({ theme: { color } }) => color.black};
+ border-radius: 4px;
+
+ transition: background-color 0.3s ease;
+
+ @media (hover: hover) {
+ &:hover {
+ background-color: ${({ theme: { color } }) => color.secondary};
+ }
+ }
+
+ @media (max-width: ${({ theme }) => theme.breakPoints.xs}) {
+ border-radius: 0;
+ }
+`;
+
+const AlbumCoverWrapper = styled.div`
+ aspect-ratio: 1 / 1;
+ width: 100px;
+
+ @media (max-width: ${({ theme }) => theme.breakPoints.md}) {
+ width: 70px;
+ }
+`;
+
+const AlbumCover = styled.img`
+ width: 100%;
+`;
+
+const FlexInfo = styled(Flex)`
+ overflow: hidden;
+ flex: 3 1 0;
+
+ padding: 8px 0;
+
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ @media (max-width: ${({ theme }) => theme.breakPoints.md}) {
+ padding: 6px 0;
+ }
+`;
+
+const SongTitle = styled.div`
+ overflow: hidden;
+
+ font-size: 16px;
+ font-weight: 700;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+`;
+
+const Singer = styled.div`
+ overflow: hidden;
+
+ font-size: 14px;
+ color: ${({ theme: { color } }) => color.subText};
+ text-overflow: ellipsis;
+ white-space: nowrap;
+`;
+
+const VideoLength = styled(Flex)`
+ flex: 1 0 0;
+ font-size: 16px;
+
+ @media (max-width: ${({ theme }) => theme.breakPoints.md}) {
+ font-size: 14px;
+ }
+`;
diff --git a/frontend/src/features/singer/components/SingerSongList.stories.tsx b/frontend/src/features/singer/components/SingerSongList.stories.tsx
new file mode 100644
index 000000000..c46d7d1c6
--- /dev/null
+++ b/frontend/src/features/singer/components/SingerSongList.stories.tsx
@@ -0,0 +1,17 @@
+import singerSongs from '@/mocks/fixtures/searchedSingers.json';
+import SingerSongList from './SingerSongList';
+import type { Meta, StoryObj } from '@storybook/react';
+
+const singerSong = singerSongs[0];
+
+const meta = {
+ component: SingerSongList,
+ title: 'singer/SingerSongList',
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () => ,
+};
diff --git a/frontend/src/features/singer/components/SingerSongList.tsx b/frontend/src/features/singer/components/SingerSongList.tsx
new file mode 100644
index 000000000..4dc2d7b8d
--- /dev/null
+++ b/frontend/src/features/singer/components/SingerSongList.tsx
@@ -0,0 +1,44 @@
+import styled from 'styled-components';
+import Flex from '@/shared/components/Flex/Flex';
+import SingerSongItem from './SingerSongItem';
+import type { SingersSong } from '../types/singer.type';
+
+interface SingerSongListProps {
+ songs: SingersSong[];
+ title: string;
+}
+
+const SingerSongList = ({ songs, title }: SingerSongListProps) => {
+ return (
+
+ {title}
+
+ {songs.map((song) => (
+
+ ))}
+
+
+ );
+};
+
+export default SingerSongList;
+
+const Container = styled.div`
+ flex: 1;
+`;
+
+const SongsItemList = styled(Flex)`
+ overflow-y: scroll;
+ width: 100%;
+`;
+
+const Title = styled.h2`
+ margin-bottom: 18px;
+ font-size: 28px;
+ font-weight: 700;
+ color: ${({ theme: { color } }) => color.white};
+
+ @media (max-width: ${({ theme }) => theme.breakPoints.xs}) {
+ font-size: 24px;
+ }
+`;
diff --git a/frontend/src/features/singer/remotes/singer.ts b/frontend/src/features/singer/remotes/singer.ts
new file mode 100644
index 000000000..bea1d670b
--- /dev/null
+++ b/frontend/src/features/singer/remotes/singer.ts
@@ -0,0 +1,6 @@
+import fetcher from '@/shared/remotes';
+import type { SingerDetail } from '../types/singer.type';
+
+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/singer/types/singer.type.ts
similarity index 59%
rename from frontend/src/features/search/types/search.ts
rename to frontend/src/features/singer/types/singer.type.ts
index 133dad1b7..fb7e1365b 100644
--- a/frontend/src/features/search/types/search.ts
+++ b/frontend/src/features/singer/types/singer.type.ts
@@ -1,17 +1,12 @@
-interface SingersSong {
+export interface SingersSong {
id: number;
+ singer: string;
title: string;
albumCoverUrl: string;
videoLength: number;
}
-export interface SingerSearchPreview {
- id: number;
- singer: string;
- profileImageUrl: string;
-}
-
-export interface SingerSearchResult {
+export interface SingerDetail {
id: number;
singer: string;
profileImageUrl: string;
diff --git a/frontend/src/features/songs/components/CarouselItem.tsx b/frontend/src/features/songs/components/CarouselItem.tsx
index 4221331c1..9992762c2 100644
--- a/frontend/src/features/songs/components/CarouselItem.tsx
+++ b/frontend/src/features/songs/components/CarouselItem.tsx
@@ -1,41 +1,22 @@
-import { useNavigate } from 'react-router-dom';
+import { Link } from 'react-router-dom';
import { styled } from 'styled-components';
import emptyPlay from '@/assets/icon/empty-play.svg';
-import { useAuthContext } from '@/features/auth/components/AuthProvider';
-import LoginModal from '@/features/auth/components/LoginModal';
import Thumbnail from '@/features/songs/components/Thumbnail';
-import useModal from '@/shared/components/Modal/hooks/useModal';
import Spacing from '@/shared/components/Spacing';
import ROUTE_PATH from '@/shared/constants/path';
import { toMinSecText } from '@/shared/utils/convertTime';
-import type { VotingSong } from '../types/Song.type';
+import type { RecentSong } from '@/shared/types/song';
interface CarouselItemProps {
- votingSong: VotingSong;
+ recentSong: RecentSong;
}
-const CarouselItem = ({ votingSong }: CarouselItemProps) => {
- const { id, singer, title, videoLength, albumCoverUrl } = votingSong;
-
- const { isOpen, openModal, closeModal } = useModal();
-
- const { user } = useAuthContext();
- const isLoggedIn = !!user;
-
- const navigate = useNavigate();
- const goToPartCollectingPage = () => navigate(`${ROUTE_PATH.COLLECT}/${id}`);
+const CarouselItem = ({ recentSong }: CarouselItemProps) => {
+ const { id, singer, title, videoLength, albumCoverUrl } = recentSong;
return (
-
-
-
+
@@ -46,7 +27,7 @@ const CarouselItem = ({ votingSong }: CarouselItemProps) => {
{toMinSecText(videoLength)}
-
+
);
};
@@ -58,7 +39,7 @@ const Wrapper = styled.li`
min-width: 350px;
`;
-const CollectingLink = styled.a`
+const FlexLink = styled(Link)`
display: flex;
justify-content: center;
padding: 10px;
diff --git a/frontend/src/features/songs/components/CollectionCarousel.stories.tsx b/frontend/src/features/songs/components/CollectionCarousel.stories.tsx
index 3e9126acb..7f20350e0 100644
--- a/frontend/src/features/songs/components/CollectionCarousel.stories.tsx
+++ b/frontend/src/features/songs/components/CollectionCarousel.stories.tsx
@@ -6,7 +6,7 @@ import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta = {
component: CollectionCarousel,
- title: 'CollectionCarousel',
+ title: 'songs/CollectionCarousel',
decorators: [
(Story) => (
diff --git a/frontend/src/features/songs/components/KillingPartTrack.stories.tsx b/frontend/src/features/songs/components/KillingPartTrack.stories.tsx
index 625d62858..a7ef50472 100644
--- a/frontend/src/features/songs/components/KillingPartTrack.stories.tsx
+++ b/frontend/src/features/songs/components/KillingPartTrack.stories.tsx
@@ -9,7 +9,7 @@ import type { Meta, StoryObj } from '@storybook/react';
// FIXME: 재생시 `YT is not defined` 에러 발생
const meta = {
component: KillingPartTrack,
- title: 'KillingPartTrack',
+ title: 'killingPart/KillingPartTrack',
decorators: [
(Story) => {
return (
diff --git a/frontend/src/features/songs/components/KillingPartTrackList.stories.tsx b/frontend/src/features/songs/components/KillingPartTrackList.stories.tsx
index 15e625634..979087a2c 100644
--- a/frontend/src/features/songs/components/KillingPartTrackList.stories.tsx
+++ b/frontend/src/features/songs/components/KillingPartTrackList.stories.tsx
@@ -9,13 +9,12 @@ import type { Meta, StoryObj } from '@storybook/react';
// FIXME: 재생시 `YT is not defined` 에러 발생
const meta = {
component: KillingPartTrackList,
- title: 'KillingPartTrackList',
+ title: 'killingPart/KillingPartTrackList',
decorators: [
(Story) => {
return (
- {/* FIXME: time prop을 KillingPartTrack 스토리북 컴포넌트를 보고 임의로 똑같이 넣었습니다. */}
diff --git a/frontend/src/features/songs/components/SongItem.stories.tsx b/frontend/src/features/songs/components/SongItem.stories.tsx
index 7cbfaace6..eb59ac905 100644
--- a/frontend/src/features/songs/components/SongItem.stories.tsx
+++ b/frontend/src/features/songs/components/SongItem.stories.tsx
@@ -4,6 +4,7 @@ import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta = {
component: SongItem,
+ title: 'songs/SongItem',
};
export default meta;
diff --git a/frontend/src/features/songs/remotes/song.ts b/frontend/src/features/songs/remotes/song.ts
index c1b0159de..9039cc549 100644
--- a/frontend/src/features/songs/remotes/song.ts
+++ b/frontend/src/features/songs/remotes/song.ts
@@ -1,9 +1,12 @@
import fetcher from '@/shared/remotes';
import type { Genre, Song } from '../types/Song.type';
-import type { SongDetail } from '@/shared/types/song';
+import type { RecentSong } from '@/shared/types/song';
-export const getSongDetail = async (songId: number): Promise => {
- return await fetcher(`/songs/${songId}`, 'GET');
+// 메인 케러셀 최신순 노래 n개 조회 api - 쿼리파람 없는경우, 응답 기본값은 5개입니다.
+export const getRecentSongs = async (songCount?: number): Promise => {
+ const query = songCount ? `?size=${songCount}` : '';
+
+ return await fetcher(`/songs/recent${query}`, 'GET');
};
export const getHighLikedSongs = async (genre: Genre): Promise => {
diff --git a/frontend/src/features/songs/remotes/useGetSongDetail.ts b/frontend/src/features/songs/remotes/useGetSongDetail.ts
deleted file mode 100644
index 6c5dbf111..000000000
--- a/frontend/src/features/songs/remotes/useGetSongDetail.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import useFetch from '@/shared/hooks/useFetch';
-import { getSongDetail } from './song';
-import type { SongDetail } from '@/shared/types/song';
-
-export const useGetSongDetail = (songId: number) => {
- const {
- data: songDetail,
- isLoading,
- error,
- fetchData,
- } = useFetch(() => getSongDetail(songId));
-
- return { songDetail, isLoading, error, fetchData };
-};
diff --git a/frontend/src/mocks/fixtures/recentSongs.json b/frontend/src/mocks/fixtures/recentSongs.json
new file mode 100644
index 000000000..2b0191128
--- /dev/null
+++ b/frontend/src/mocks/fixtures/recentSongs.json
@@ -0,0 +1,44 @@
+[
+ {
+ "id": 1,
+ "title": "Ditto",
+ "singer": "NewJeans",
+ "videoLength": 187,
+ "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/120/quality/80/optimize"
+ },
+ {
+ "id": 2,
+ "title": "Still With You",
+ "singer": "정국",
+ "videoLength": 239,
+ "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/120/quality/80/optimize"
+ },
+ {
+ "id": 3,
+ "title": "Broken Melodies",
+ "singer": "NCT DREAM",
+ "videoLength": 227,
+ "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/120/quality/80/optimize"
+ },
+ {
+ "id": 4,
+ "title": "Candy",
+ "singer": "NCT DREAM",
+ "videoLength": 220,
+ "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/120/quality/80/optimize"
+ },
+ {
+ "id": 5,
+ "title": "Kitsch",
+ "singer": "IVE (아이브)",
+ "videoLength": 195,
+ "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/120/quality/80/optimize"
+ },
+ {
+ "id": 6,
+ "title": "UNFORGIVEN (feat. Nile Rodgers)",
+ "singer": "LE SSERAFIM (르세라핌)",
+ "videoLength": 181,
+ "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/120/quality/80/optimize"
+ }
+]
diff --git a/frontend/src/mocks/fixtures/searchedSingers.json b/frontend/src/mocks/fixtures/searchedSingers.json
index 73583d623..d8749784d 100644
--- a/frontend/src/mocks/fixtures/searchedSingers.json
+++ b/frontend/src/mocks/fixtures/searchedSingers.json
@@ -2,25 +2,28 @@
{
"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",
+ "profileImageUrl": "https://cdnimg.melon.co.kr/cm2/artistcrop/images/007/12/452/712452_20230821182358_500.jpg?f9ab949ec336274795e07b2719addab6/melon/resize/240/quality/100/optimize",
"totalSongCount": 6,
"songs": [
{
"id": 1,
+ "singer": "악동뮤지션",
"title": "계란찜의 꿈",
- "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/artistcrop/images/007/12/452/712452_20230821182358_500.jpg?f9ab949ec336274795e07b2719addab6/melon/resize/416/quality/80/optimize",
+ "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/artistcrop/images/007/12/452/712452_20230821182358_500.jpg?f9ab949ec336274795e07b2719addab6/melon/resize/240/quality/100/optimize",
"videoLength": 111
},
{
"id": 2,
+ "singer": "악동뮤지션",
"title": "수란의 꿈",
- "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/artistcrop/images/007/12/452/712452_20230821182358_500.jpg?f9ab949ec336274795e07b2719addab6/melon/resize/416/quality/80/optimize",
+ "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/artistcrop/images/007/12/452/712452_20230821182358_500.jpg?f9ab949ec336274795e07b2719addab6/melon/resize/240/quality/100/optimize",
"videoLength": 222
},
{
"id": 3,
+ "singer": "악동뮤지션",
"title": "서니사이드업의 서니사이드업의 서니사이드업의 서니사이드업의 서니사이드업의 서니사이드업의 서니사이드업의 서니사이드업의 꿈",
- "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/artistcrop/images/007/12/452/712452_20230821182358_500.jpg?f9ab949ec336274795e07b2719addab6/melon/resize/416/quality/80/optimize",
+ "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/artistcrop/images/007/12/452/712452_20230821182358_500.jpg?f9ab949ec336274795e07b2719addab6/melon/resize/240/quality/100/optimize",
"videoLength": 333
}
]
@@ -28,25 +31,28 @@
{
"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",
+ "profileImageUrl": "https://cdnimg.melon.co.kr/cm2/artistcrop/images/007/12/452/712452_20230821182358_500.jpg?f9ab949ec336274795e07b2719addab6/melon/resize/240/quality/100/optimize",
"totalSongCount": 12,
"songs": [
{
"id": 4,
+ "singer": "악동뮤지션",
"title": "계란찜의 꿈",
- "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/artistcrop/images/007/12/452/712452_20230821182358_500.jpg?f9ab949ec336274795e07b2719addab6/melon/resize/416/quality/80/optimize",
+ "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/artistcrop/images/007/12/452/712452_20230821182358_500.jpg?f9ab949ec336274795e07b2719addab6/melon/resize/240/quality/100/optimize",
"videoLength": 111
},
{
"id": 5,
+ "singer": "악동뮤지션",
"title": "수란의 꿈",
- "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/artistcrop/images/007/12/452/712452_20230821182358_500.jpg?f9ab949ec336274795e07b2719addab6/melon/resize/416/quality/80/optimize",
+ "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/artistcrop/images/007/12/452/712452_20230821182358_500.jpg?f9ab949ec336274795e07b2719addab6/melon/resize/240/quality/100/optimize",
"videoLength": 222
},
{
"id": 6,
+ "singer": "악동뮤지션",
"title": "서니사이드업의 서니사이드업의 서니사이드업의 서니사이드업의 서니사이드업의 서니사이드업의 서니사이드업의 서니사이드업의 꿈",
- "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/artistcrop/images/007/12/452/712452_20230821182358_500.jpg?f9ab949ec336274795e07b2719addab6/melon/resize/416/quality/80/optimize",
+ "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/artistcrop/images/007/12/452/712452_20230821182358_500.jpg?f9ab949ec336274795e07b2719addab6/melon/resize/240/quality/100/optimize",
"videoLength": 333
}
]
@@ -54,25 +60,28 @@
{
"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",
+ "profileImageUrl": "https://cdnimg.melon.co.kr/cm2/artistcrop/images/007/12/452/712452_20230821182358_500.jpg?f9ab949ec336274795e07b2719addab6/melon/resize/240/quality/100/optimize",
"totalSongCount": 18,
"songs": [
{
"id": 7,
+ "singer": "악동",
"title": "계란찜의 꿈",
- "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/artistcrop/images/007/12/452/712452_20230821182358_500.jpg?f9ab949ec336274795e07b2719addab6/melon/resize/416/quality/80/optimize",
+ "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/artistcrop/images/007/12/452/712452_20230821182358_500.jpg?f9ab949ec336274795e07b2719addab6/melon/resize/240/quality/100/optimize",
"videoLength": 111
},
{
"id": 8,
+ "singer": "악동",
"title": "수란의 꿈",
- "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/artistcrop/images/007/12/452/712452_20230821182358_500.jpg?f9ab949ec336274795e07b2719addab6/melon/resize/416/quality/80/optimize",
+ "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/artistcrop/images/007/12/452/712452_20230821182358_500.jpg?f9ab949ec336274795e07b2719addab6/melon/resize/240/quality/100/optimize",
"videoLength": 222
},
{
"id": 9,
+ "singer": "악동",
"title": "서니사이드업의 서니사이드업의 서니사이드업의 서니사이드업의 서니사이드업의 서니사이드업의 서니사이드업의 서니사이드업의 꿈",
- "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/artistcrop/images/007/12/452/712452_20230821182358_500.jpg?f9ab949ec336274795e07b2719addab6/melon/resize/416/quality/80/optimize",
+ "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/artistcrop/images/007/12/452/712452_20230821182358_500.jpg?f9ab949ec336274795e07b2719addab6/melon/resize/240/quality/100/optimize",
"videoLength": 333
}
]
diff --git a/frontend/src/mocks/fixtures/songEntries.json b/frontend/src/mocks/fixtures/songEntries.json
index 44c78a6e2..29001cb51 100644
--- a/frontend/src/mocks/fixtures/songEntries.json
+++ b/frontend/src/mocks/fixtures/songEntries.json
@@ -403,11 +403,11 @@
],
"currentSong": {
"id": 11,
- "title": "모래 알갱이",
- "singer": "임영웅",
+ "title": "후라이의 꿈",
+ "singer": "악동뮤지션",
"videoLength": 221,
- "songVideoId": "3_wOZrzmQ1o",
- "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/120/quality/80/optimize",
+ "songVideoId": "3kGAlp_PNUg",
+ "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/113/09/190/11309190_20230818161008_500.jpg?93148adc9d3b5622f4c4dca1e429650e/melon/resize/282/quality/80/optimize",
"killingParts": [
{
"id": 33,
diff --git a/frontend/src/mocks/handlers/index.ts b/frontend/src/mocks/handlers/index.ts
index 4189b9d25..f416102a7 100644
--- a/frontend/src/mocks/handlers/index.ts
+++ b/frontend/src/mocks/handlers/index.ts
@@ -1,7 +1,8 @@
import memberHandlers from './memberHandlers';
import searchHandlers from './searchHandlers';
+import singerHandlers from './singerHandlers';
import songsHandlers from './songsHandlers';
-const handlers = [...memberHandlers, ...searchHandlers, ...songsHandlers];
+const handlers = [...memberHandlers, ...searchHandlers, ...singerHandlers, ...songsHandlers];
export default handlers;
diff --git a/frontend/src/mocks/handlers/searchHandlers.ts b/frontend/src/mocks/handlers/searchHandlers.ts
index 56d1fdf4b..2cd8dce4d 100644
--- a/frontend/src/mocks/handlers/searchHandlers.ts
+++ b/frontend/src/mocks/handlers/searchHandlers.ts
@@ -5,9 +5,10 @@ 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');
+ // 검색 미리보기, 검색완료 페이지
+ rest.get(`${BASE_URL}/search`, (req, res, ctx) => {
+ const query = req.url.searchParams.get('keyword') ?? '';
+ const [singer, song] = req.url.searchParams.getAll('type');
const testQueries = ['악동뮤지션', '악동', '뮤지션'];
const isPreviewRequest = singer !== undefined && song === undefined;
@@ -29,19 +30,6 @@ const searchHandlers = [
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/singerHandlers.ts b/frontend/src/mocks/handlers/singerHandlers.ts
new file mode 100644
index 000000000..c119dd812
--- /dev/null
+++ b/frontend/src/mocks/handlers/singerHandlers.ts
@@ -0,0 +1,22 @@
+import { rest } from 'msw';
+import searchedSingers from '@/mocks/fixtures/searchedSingers.json';
+
+const { BASE_URL } = process.env;
+
+const singerHandlers = [
+ // 가수 상세페이지
+ rest.get(`${BASE_URL}/singers/:singerId`, (req, res, ctx) => {
+ const { singerId } = req.params;
+
+ const numberSingerId = Number(singerId as string);
+ const searchedSinger = searchedSingers[numberSingerId - 1];
+
+ if (searchedSinger !== undefined) {
+ return res(ctx.status(200), ctx.json(searchedSinger));
+ }
+
+ return res(ctx.status(400), ctx.json({}));
+ }),
+];
+
+export default singerHandlers;
diff --git a/frontend/src/mocks/handlers/songsHandlers.ts b/frontend/src/mocks/handlers/songsHandlers.ts
index 8b4363840..01a793b45 100644
--- a/frontend/src/mocks/handlers/songsHandlers.ts
+++ b/frontend/src/mocks/handlers/songsHandlers.ts
@@ -3,8 +3,8 @@ import comments from '../fixtures/comments.json';
import extraNextSongDetails from '../fixtures/extraNextSongDetails.json';
import extraPrevSongDetails from '../fixtures/extraPrevSongDetails.json';
import popularSongs from '../fixtures/popularSongs.json';
+import recentSongs from '../fixtures/recentSongs.json';
import songEntries from '../fixtures/songEntries.json';
-import votingSongs from '../fixtures/votingSongs.json';
import type { KillingPartPostRequest } from '@/shared/types/killingPart';
const { BASE_URL } = process.env;
@@ -54,8 +54,12 @@ const songsHandlers = [
return res(ctx.status(200), ctx.json(extraNextSongDetails));
}),
- rest.get(`${BASE_URL}/voting-songs`, (req, res, ctx) => {
- return res(ctx.status(200), ctx.json(votingSongs));
+ rest.get(`${BASE_URL}/songs/recent`, (req, res, ctx) => {
+ const size = req.url.searchParams.get('size');
+
+ const slicedRecentSongs = size ? recentSongs.slice(0, Number(size)) : recentSongs.slice(0, 5);
+
+ return res(ctx.status(200), ctx.json(slicedRecentSongs));
}),
rest.delete(`${BASE_URL}/member-parts/:partId`, (req, res, ctx) => {
diff --git a/frontend/src/pages/MainPage.tsx b/frontend/src/pages/MainPage.tsx
index 008b7be2c..7d93ba7ea 100644
--- a/frontend/src/pages/MainPage.tsx
+++ b/frontend/src/pages/MainPage.tsx
@@ -3,20 +3,18 @@ import CarouselItem from '@/features/songs/components/CarouselItem';
import CollectionCarousel from '@/features/songs/components/CollectionCarousel';
import SongItemList from '@/features/songs/components/SongItemList';
import GENRES from '@/features/songs/constants/genres';
+import { getRecentSongs } from '@/features/songs/remotes/song';
import Spacing from '@/shared/components/Spacing';
import SRHeading from '@/shared/components/SRHeading';
import useFetch from '@/shared/hooks/useFetch';
-import fetcher from '@/shared/remotes';
-import type { Genre, VotingSong } from '@/features/songs/types/Song.type';
+import type { Genre } from '@/features/songs/types/Song.type';
const genres = Object.keys(GENRES) as Genre[];
const MainPage = () => {
- const { data: votingSongs } = useFetch(() => fetcher('/voting-songs', 'GET'));
+ const { data: recentSongs } = useFetch(() => getRecentSongs());
- if (!votingSongs) return null;
-
- const isEmptyVotingSongs = votingSongs.length === 0;
+ if (!recentSongs) return null;
return (
@@ -24,15 +22,9 @@ const MainPage = () => {
현재 킬링파트 등록중인 노래
- {isEmptyVotingSongs ? (
-
- 수집중인 노래가 곧 등록될 예정입니다.
-
- ) : (
- votingSongs.map((votingSong) => {
- return ;
- })
- )}
+ {recentSongs.map((recentSong) => (
+
+ ))}
{genres.map((genre) => (
@@ -65,12 +57,3 @@ const Title = styled.h2`
font-weight: 700;
color: white;
`;
-
-const EmptyMessage = styled.li`
- display: flex;
- align-items: center;
- justify-content: center;
-
- width: 100%;
- min-width: 350px;
-`;
diff --git a/frontend/src/pages/SearchResultPage.tsx b/frontend/src/pages/SearchResultPage.tsx
index 4a9084166..054185c17 100644
--- a/frontend/src/pages/SearchResultPage.tsx
+++ b/frontend/src/pages/SearchResultPage.tsx
@@ -1,9 +1,82 @@
+import React, { useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import styled from 'styled-components';
+import { getSingerSearch } from '@/features/search/remotes/search';
+import SingerBanner from '@/features/singer/components/SingerBanner';
+import SingerSongList from '@/features/singer/components/SingerSongList';
+import Flex from '@/shared/components/Flex/Flex';
+import Spacing from '@/shared/components/Spacing';
+import ROUTE_PATH from '@/shared/constants/path';
+import useFetch from '@/shared/hooks/useFetch';
import useValidSearchParams from '@/shared/hooks/useValidSearchParams';
const SearchResultPage = () => {
const { name } = useValidSearchParams('name');
+ const navigate = useNavigate();
- return {name}
;
+ const { data: singerDetailList, fetchData: refetchSingerSearch } = useFetch(() =>
+ getSingerSearch(name)
+ );
+
+ useEffect(() => {
+ refetchSingerSearch();
+ }, [name]);
+
+ if (!singerDetailList) return;
+
+ const goToSingerDetailPage = (singerId: number) =>
+ navigate(`/${ROUTE_PATH.SINGER_DETAIL}/${singerId}`);
+
+ return (
+
+ 검색 결과
+
+ {singerDetailList.map(({ id: singerId, profileImageUrl, singer, totalSongCount, songs }) => (
+
+
+ goToSingerDetailPage(singerId)}
+ />
+
+
+
+
+
+ ))}
+
+ );
};
export default SearchResultPage;
+
+const Container = styled(Flex)`
+ width: 100%;
+ padding-top: ${({ theme: { headerHeight } }) => headerHeight.desktop};
+
+ @media (max-width: ${({ theme }) => theme.breakPoints.xs}) {
+ padding-top: ${({ theme: { headerHeight } }) => headerHeight.mobile};
+ }
+
+ @media (max-width: ${({ theme }) => theme.breakPoints.xxs}) {
+ padding-top: ${({ theme: { headerHeight } }) => headerHeight.xxs};
+ }
+`;
+
+const FlexSearchResultContainer = styled(Flex)`
+ width: 100%;
+`;
+
+const Title = styled.h1`
+ font-size: 28px;
+ font-weight: 700;
+`;
+
+const UnderLine = styled.div`
+ width: 100%;
+ margin: 32px 0;
+ border-bottom: 1px solid ${({ theme: { color } }) => color.black200};
+ border-radius: 1px;
+`;
diff --git a/frontend/src/pages/SingerDetailPage.tsx b/frontend/src/pages/SingerDetailPage.tsx
index 3c977f7e0..95d1bfc4a 100644
--- a/frontend/src/pages/SingerDetailPage.tsx
+++ b/frontend/src/pages/SingerDetailPage.tsx
@@ -1,9 +1,54 @@
+import { useEffect } from 'react';
+import styled from 'styled-components';
+import SingerBanner from '@/features/singer/components/SingerBanner';
+import SingerSongList from '@/features/singer/components/SingerSongList';
+import { getSingerDetail } from '@/features/singer/remotes/singer';
+import Flex from '@/shared/components/Flex/Flex';
+import Spacing from '@/shared/components/Spacing';
+import useFetch from '@/shared/hooks/useFetch';
import useValidParams from '@/shared/hooks/useValidParams';
const SingerDetailPage = () => {
const { singerId } = useValidParams();
- return {singerId}
;
+ const { data: singerDetail, fetchData: refetchSingerDetail } = useFetch(() =>
+ getSingerDetail(Number(singerId))
+ );
+
+ useEffect(() => {
+ refetchSingerDetail();
+ }, [singerId]);
+
+ if (!singerDetail) return null;
+
+ const { profileImageUrl, singer, songs, totalSongCount } = singerDetail;
+
+ return (
+
+
+
+
+
+
+
+ );
};
export default SingerDetailPage;
+
+const Container = styled(Flex)`
+ width: 100%;
+ padding-top: ${({ theme: { headerHeight } }) => headerHeight.desktop};
+
+ @media (max-width: ${({ theme }) => theme.breakPoints.xs}) {
+ padding-top: ${({ theme: { headerHeight } }) => headerHeight.mobile};
+ }
+
+ @media (max-width: ${({ theme }) => theme.breakPoints.xxs}) {
+ padding-top: ${({ theme: { headerHeight } }) => headerHeight.xxs};
+ }
+`;
diff --git a/frontend/src/shared/components/BottomSheet/BottomSheet.stories.tsx b/frontend/src/shared/components/BottomSheet/BottomSheet.stories.tsx
index 5680adccc..332e94a0f 100644
--- a/frontend/src/shared/components/BottomSheet/BottomSheet.stories.tsx
+++ b/frontend/src/shared/components/BottomSheet/BottomSheet.stories.tsx
@@ -4,6 +4,7 @@ import BottomSheet from './BottomSheet';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta = {
+ title: 'shared/BottomSheet',
component: BottomSheet,
};
diff --git a/frontend/src/shared/components/Modal/Modal.stories.tsx b/frontend/src/shared/components/Modal/Modal.stories.tsx
index ca520da1f..7c6671a5c 100644
--- a/frontend/src/shared/components/Modal/Modal.stories.tsx
+++ b/frontend/src/shared/components/Modal/Modal.stories.tsx
@@ -3,6 +3,7 @@ import Modal from './Modal';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta = {
+ title: 'shared/Modal',
component: Modal,
};
diff --git a/frontend/src/shared/components/Spacing.tsx b/frontend/src/shared/components/Spacing.tsx
index f0049c56e..28f825472 100644
--- a/frontend/src/shared/components/Spacing.tsx
+++ b/frontend/src/shared/components/Spacing.tsx
@@ -1,9 +1,63 @@
-import { styled } from 'styled-components';
+import styled, { css } from 'styled-components';
+import type { BreakPoints } from '@/shared/types/theme';
-const Spacing = styled.div<{ direction: 'horizontal' | 'vertical'; size: number }>`
+interface Spacing {
+ direction: 'horizontal' | 'vertical';
+ size: number;
+}
+
+interface ResponsiveSpacing extends Partial>> {}
+
+interface SpacingProps extends Spacing, ResponsiveSpacing {}
+
+const Spacing = styled.div`
flex: none;
min-width: ${({ direction, size }) => (direction === 'horizontal' ? `${size}px` : '0')};
min-height: ${({ direction, size }) => (direction === 'vertical' ? `${size}px` : '0')};
+
+ @media (max-width: ${({ theme }) => theme.breakPoints.xxl}) {
+ ${({ $xxl, direction, size }) => spacingCss($xxl, direction, size)}
+ }
+
+ @media (max-width: ${({ theme }) => theme.breakPoints.xl}) {
+ ${({ $xl, direction, size }) => spacingCss($xl, direction, size)}
+ }
+
+ @media (max-width: ${({ theme }) => theme.breakPoints.lg}) {
+ ${({ $lg, direction, size }) => spacingCss($lg, direction, size)}
+ }
+
+ @media (max-width: ${({ theme }) => theme.breakPoints.md}) {
+ ${({ $md, direction, size }) => spacingCss($md, direction, size)}
+ }
+
+ @media (max-width: ${({ theme }) => theme.breakPoints.sm}) {
+ ${({ $sm, direction, size }) => spacingCss($sm, direction, size)}
+ }
+
+ @media (max-width: ${({ theme }) => theme.breakPoints.xs}) {
+ ${({ $xs, direction, size }) => spacingCss($xs, direction, size)}
+ }
+
+ @media (max-width: ${({ theme }) => theme.breakPoints.xxs}) {
+ ${({ $xxs, direction, size }) => spacingCss($xxs, direction, size)}
+ }
`;
+const spacingCss = (
+ responsiveSpacing?: Partial,
+ originalDir?: Spacing['direction'],
+ originalSize?: Spacing['size']
+) => {
+ if (!responsiveSpacing) return;
+
+ const direction = responsiveSpacing.direction ?? originalDir;
+ const size = responsiveSpacing.size ?? originalSize;
+
+ return css`
+ min-width: ${direction === 'horizontal' ? `${size}px` : '0'};
+ min-height: ${direction === 'vertical' ? `${size}px` : '0'};
+ `;
+};
+
export default Spacing;
diff --git a/frontend/src/shared/components/Toast/Toast.stories.tsx b/frontend/src/shared/components/Toast/Toast.stories.tsx
index 956e8b55c..fcf5a672d 100644
--- a/frontend/src/shared/components/Toast/Toast.stories.tsx
+++ b/frontend/src/shared/components/Toast/Toast.stories.tsx
@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react';
const meta = {
component: Toast,
- title: 'Toast',
+ title: 'shared/Toast',
args: {
message: `I'm toast`,
},
diff --git a/frontend/src/shared/components/ToggleSwitch/ToggleSwitch.stories.tsx b/frontend/src/shared/components/ToggleSwitch/ToggleSwitch.stories.tsx
index 82dc353c1..313a2d174 100644
--- a/frontend/src/shared/components/ToggleSwitch/ToggleSwitch.stories.tsx
+++ b/frontend/src/shared/components/ToggleSwitch/ToggleSwitch.stories.tsx
@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react';
const meta = {
component: ToggleSwitch,
- title: 'ToggleSwitch',
+ title: 'shared/ToggleSwitch',
} satisfies Meta;
export default meta;
diff --git a/frontend/src/shared/styles/GlobalStyles.ts b/frontend/src/shared/styles/GlobalStyles.ts
index 50122fffc..fbce5564b 100644
--- a/frontend/src/shared/styles/GlobalStyles.ts
+++ b/frontend/src/shared/styles/GlobalStyles.ts
@@ -61,7 +61,7 @@ const GlobalStyles = createGlobalStyle`
border-collapse: collapse;
}
- ol, ul {
+ ol, ul, li {
list-style: none;
}
diff --git a/frontend/src/shared/types/song.ts b/frontend/src/shared/types/song.ts
index d7ecd88e2..fb0085669 100644
--- a/frontend/src/shared/types/song.ts
+++ b/frontend/src/shared/types/song.ts
@@ -40,3 +40,11 @@ export interface SongInfo {
genre: Genre;
killingParts: KillingPart[];
}
+
+export interface RecentSong {
+ id: number;
+ title: string;
+ singer: string;
+ videoLength: number;
+ albumCoverUrl: string;
+}