Skip to content

Commit

Permalink
Feat/#513, #523 검색완료, 가수 상세 페이지 구현 및 메인페이지 케러셀 정책 변경 반영 (#525)
Browse files Browse the repository at this point in the history
* 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하도록 변경
  • Loading branch information
Creative-Lee authored Oct 19, 2023
1 parent 27455eb commit bab208b
Show file tree
Hide file tree
Showing 38 changed files with 673 additions and 133 deletions.
3 changes: 3 additions & 0 deletions frontend/src/assets/icon/right-long-arrow.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { Meta, StoryObj } from '@storybook/react';

const meta = {
component: SearchBar,
title: 'SearchBar',
title: 'search/SearchBar',
} satisfies Meta<typeof SearchBar>;

export default meta;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof SearchPreviewSheet>;

export default meta;
type Story = StoryObj<typeof SearchPreviewSheet>;

export const Default: Story = {
render: () => <SearchPreviewSheet result={searchedSingerPreview} endSearch={() => {}} />,
};
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
13 changes: 5 additions & 8 deletions frontend/src/features/search/remotes/search.ts
Original file line number Diff line number Diff line change
@@ -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<SingerSearchPreview[]> => {
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<SingerSearchResult[]> => {
export const getSingerSearch = async (query: string): Promise<SingerDetail[]> => {
const encodedQuery = encodeURIComponent(query);
return await fetcher(`/singers?name=${encodedQuery}&search=singer&search=song`, 'GET');
};

export const getSingerDetail = async (singerId: number): Promise<SingerSearchResult> => {
return await fetcher(`/singers/${singerId}`, 'GET');
return await fetcher(`/search?keyword=${encodedQuery}&type=singer&type=song`, 'GET');
};
5 changes: 5 additions & 0 deletions frontend/src/features/search/types/search.type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export interface SingerSearchPreview {
id: number;
singer: string;
profileImageUrl: string;
}
15 changes: 15 additions & 0 deletions frontend/src/features/singer/components/SingerBanner.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof SingerBanner>;

export default meta;
type Story = StoryObj<typeof SingerBanner>;

export const Default: Story = {
render: () => <SingerBanner {...searchedSingers[0]} />,
};
107 changes: 107 additions & 0 deletions frontend/src/features/singer/components/SingerBanner.tsx
Original file line number Diff line number Diff line change
@@ -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<SingerDetail, 'profileImageUrl' | 'singer' | 'totalSongCount'> {
onClick?: () => void;
}

const SingerBanner = ({ profileImageUrl, singer, totalSongCount, onClick }: SingerBannerProps) => {
const clickable = typeof onClick === 'function';

return (
<Container>
<Title>아티스트</Title>
<SingerInfoContainer $align="center" $gap={24} onClick={onClick} $clickable={clickable}>
<ProfileImage src={profileImageUrl} alt="" />
<Flex $direction="column" $gap={16}>
<Name>{singer}</Name>
<SongCount>{`등록된 노래 ${totalSongCount}개`}</SongCount>
</Flex>
</SingerInfoContainer>
</Container>
);
};

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;
}
`;
17 changes: 17 additions & 0 deletions frontend/src/features/singer/components/SingerSongItem.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof SingerSongItem>;

export default meta;
type Story = StoryObj<typeof SingerSongItem>;

export const Default: Story = {
render: () => <SingerSongItem {...song} />,
};
116 changes: 116 additions & 0 deletions frontend/src/features/singer/components/SingerSongItem.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<SongListItem as="li">
<FlexLink to={`/${ROUTE_PATH.SONG_DETAILS}/${id}/ALL`}>
<AlbumCoverWrapper>
<AlbumCover src={albumCoverUrl} />
</AlbumCoverWrapper>
<FlexInfo $direction="column" $gap={8} $xs={{ $gap: 4 }}>
<SongTitle>{title}</SongTitle>
<Singer>{singer}</Singer>
</FlexInfo>
<VideoLength as="span" $align="center" $justify="flex-end">
{toMinSecText(videoLength)}
</VideoLength>
</FlexLink>
</SongListItem>
);
};

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;
}
`;
17 changes: 17 additions & 0 deletions frontend/src/features/singer/components/SingerSongList.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof SingerSongList>;

export default meta;
type Story = StoryObj<typeof SingerSongList>;

export const Default: Story = {
render: () => <SingerSongList songs={singerSong.songs} title="곡" />,
};
44 changes: 44 additions & 0 deletions frontend/src/features/singer/components/SingerSongList.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Container>
<Title>{title}</Title>
<SongsItemList as="ol" $direction="column" $gap={12} $align="center">
{songs.map((song) => (
<SingerSongItem key={song.id} {...song} />
))}
</SongsItemList>
</Container>
);
};

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;
}
`;
6 changes: 6 additions & 0 deletions frontend/src/features/singer/remotes/singer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import fetcher from '@/shared/remotes';
import type { SingerDetail } from '../types/singer.type';

export const getSingerDetail = async (singerId: number): Promise<SingerDetail> => {
return await fetcher(`/singers/${singerId}`, 'GET');
};
Loading

0 comments on commit bab208b

Please sign in to comment.