Skip to content

Commit

Permalink
Feat/#506 마이파트를 등록하는 기능을 추가한다 (#507)
Browse files Browse the repository at this point in the history
* test: 마이페이지에서 마이 파트 msw 추가 및 좋아요한 파트 엔드포인트 수정

* feat: LikePartItem 컴포넌트를 분리

- 컴포넌트 이름 변경: PartItem (좋아요와 마이파트 모두 사용)

* feat: PartList 구현

- 탭 전환시 스크롤이 상단으로 이동하도록 구현

* feat: PartList가 포함된 탭을 설정하는 MyPartList 컴포넌트 구현

* feat: 좋아요한 킬링파트에서 좋아요와 마이파트가 포함된 MyPartList를 보여주도록 변경

* test: songEntries mock 데이터에 myPart 추가 및 songHandler에 마이 파트를 삭제하는 msw 추가

* feat: 마이파트 삭제를 확인하는 MyPartModal 컴포넌트 구현

* feat: 마이파트 등록과 제거 구현

* test: 스토리북에 myPart 추가 및 TimerProvider 추가

- 재생되지 않는 버그를 주석으로 남김

* style: 불필요한 코드 삭제

* refactor: 마이페이지에서 기본 자기소개 부분 분리 및 함수 구현

* design: 자기소개가 3줄 정도만 보이도록 수정

- 모바일 기준 대략(72자)

* refactor: 좋아요한 파트와 마이파트를 가져오는 fetch 로직 분리

* refactor: 마이파트 제거 fetch 로직 분리

* design: 좋아요한 킬링파트와 나의 킬링파트를 포함하는 탭의 색상을 더 진하게 변경

* refactor: PartItem의 props를 스프레드로 간단하게 표현

* refactor: PartItem에서 상수 사용 및 변수 분리

* fix: fetch의 method를 대문자로 수정

* design: PartList의 scrollTop을 일정하게 유지시키기 위한 스타일 수정

* refactor: PartList의 li 태그를 PartItem으로 이동

- 스타일드 컴포넌트 이름 변경
- 스타일 및 태그 수정

* refactor: 마이페이지의 탭 타입 분리

* style: 함수명 및 스타일드 컴포넌트 명 변경

* design: font-weight 700으로 수정

* refactor: 상수 const assertion

* refactor: PartItem에 rank prop 삭제

* refactor: 탭과 보여줄 컨텐츠를 동적으로 늘릴 수 있도록 수정

* feat: 킬링파트 트랙과 마이페이지에서 탭의 접근성 향상

* fix: 킬링파트 id와 마이파트 id가 겹쳐 재생할 수 없는 현상 수정

* fix 마이 페이지의 내 킬링파트 조회 api 엔드포인트 수정

* test: msw 데이터 fixture로 분리

* fix: 반복 듣기가 되지 않는 현상 수정

* fix: 잘못된 경로 수정

* fix: myPart를 memberPart로 변경

* test: msw 데이터 필드 수정

* feat: 마이파트에 공유하는 버튼 추가

* test: 마이페이지에서 마이 파트 msw에 songVideoId 필드 추가

* feat: 마이페이지에서 나의 킬링파트는 유튜브 링크 공유로 변경

* fix: 마이페이지의 좋아요한 파트 공유 링크 수정
  • Loading branch information
cruelladevil authored Oct 19, 2023
1 parent 280182e commit 749ec05
Show file tree
Hide file tree
Showing 25 changed files with 950 additions and 335 deletions.
59 changes: 59 additions & 0 deletions frontend/src/assets/icon/trash.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
107 changes: 107 additions & 0 deletions frontend/src/features/member/components/MyPartList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { useState } from 'react';
import styled from 'styled-components';
import Spacing from '@/shared/components/Spacing';
import useFetch from '@/shared/hooks/useFetch';
import { getLikeParts, getMyParts } from '../remotes/myPage';
import PartList from './PartList';
import type { MyPageTab } from '../types/myPage';
import type { KillingPart, SongDetail } from '@/shared/types/song';

export type LikeKillingPart = Pick<
SongDetail,
'title' | 'singer' | 'albumCoverUrl' | 'songVideoId'
> &
Pick<KillingPart, 'start' | 'end'> & {
songId: number;
partId: number;
};

const MyPartList = () => {
const [tab, setTab] = useState<MyPageTab>('Like');

const { data: likes } = useFetch<LikeKillingPart[]>(getLikeParts);
const { data: myParts } = useFetch<LikeKillingPart[]>(getMyParts);

if (!likes || !myParts) {
return null;
}

const partTabItems: { tab: MyPageTab; title: string; parts: LikeKillingPart[] }[] = [
{ tab: 'Like', title: '좋아요 한 킬링파트', parts: likes },
{ tab: 'MyKillingPart', title: '내 킬링파트', parts: myParts },
];

const pressEnterChangeTab = (tab: MyPageTab) => (event: React.KeyboardEvent<HTMLLIElement>) => {
if (event.key === 'Enter') {
setTab(tab);
}
};

return (
<>
<Tabs role="tablist">
{partTabItems.map((option) => (
<TabItem
key={option.tab}
role="tab"
aria-selected={tab === option.tab}
$isActive={tab === option.tab}
onClick={() => setTab(option.tab)}
onKeyDown={pressEnterChangeTab(option.tab)}
tabIndex={0}
>
{option.title}
</TabItem>
))}
</Tabs>

<Spacing direction="vertical" size={24} />

{partTabItems.map((option) => (
<PartList
key={option.tab}
parts={option.parts}
isShow={tab === option.tab}
tab={option.tab}
/>
))}
</>
);
};

export default MyPartList;

const Tabs = styled.ul`
position: sticky;
top: ${({ theme }) => theme.headerHeight.desktop};
display: flex;
background-color: ${({ theme: { color } }) => color.black};
@media (max-width: ${({ theme }) => theme.breakPoints.xs}) {
top: ${({ theme }) => theme.headerHeight.mobile};
}
@media (max-width: ${({ theme }) => theme.breakPoints.xxs}) {
top: ${({ theme }) => theme.headerHeight.xxs};
}
`;

const TabItem = styled.li<{ $isActive?: boolean }>`
cursor: pointer;
flex-shrink: 1;
width: 100%;
padding: 15px 20px;
color: ${({ $isActive, theme: { color } }) =>
$isActive ? color.white : color.disabledBackground};
text-align: center;
border-bottom: 2px solid
${({ $isActive, theme: { color } }) => ($isActive ? color.white : color.disabled)};
transition:
color 0.3s,
border 0.3s;
`;
154 changes: 154 additions & 0 deletions frontend/src/features/member/components/PartItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { useNavigate } from 'react-router-dom';
import styled from 'styled-components';
import link from '@/assets/icon/link.svg';
import shook from '@/assets/icon/shook.svg';
import { useAuthContext } from '@/features/auth/components/AuthProvider';
import Thumbnail from '@/features/songs/components/Thumbnail';
import Spacing from '@/shared/components/Spacing';
import useToastContext from '@/shared/components/Toast/hooks/useToastContext';
import { GA_ACTIONS, GA_CATEGORIES } from '@/shared/constants/GAEventName';
import ROUTE_PATH from '@/shared/constants/path';
import sendGAEvent from '@/shared/googleAnalytics/sendGAEvent';
import { secondsToMinSec, toPlayingTimeText } from '@/shared/utils/convertTime';
import copyClipboard from '@/shared/utils/copyClipBoard';
import type { LikeKillingPart } from './MyPartList';
import type { MyPageTab } from '../types/myPage';

const { BASE_URL } = process.env;

type PartItemProps = LikeKillingPart & {
tab: MyPageTab;
};

const PartItem = ({
songId,
albumCoverUrl,
title,
singer,
start,
end,
songVideoId,
tab,
}: PartItemProps) => {
const { showToast } = useToastContext();
const { user } = useAuthContext();
const navigate = useNavigate();

const shareUrl: React.MouseEventHandler = (e) => {
e.stopPropagation();
sendGAEvent({
action: GA_ACTIONS.COPY_URL,
category: GA_CATEGORIES.MY_PAGE,
memberId: user?.memberId,
});

const shareLink =
tab === 'Like'
? `${BASE_URL?.replace('/api', '')}/${ROUTE_PATH.SONG_DETAILS}/${songId}/ALL`
: `https://youtu.be/${songVideoId}?start=${start}`;

copyClipboard(shareLink);
showToast('클립보드에 영상링크가 복사되었습니다.');
};

const goToSongDetailListPage = () => {
navigate(`/${ROUTE_PATH.SONG_DETAILS}/${songId}/ALL`);
};

const { minute: startMin, second: startSec } = secondsToMinSec(start);
const { minute: endMin, second: endSec } = secondsToMinSec(end);

return (
<PartItemGrid onClick={goToSongDetailListPage}>
<Thumbnail src={albumCoverUrl} alt={`${title}-${singer}`} />
<SongTitle>{title}</SongTitle>
<Singer>{singer}</Singer>
<TimeWrapper>
<Shook src={shook} alt="" />
<Spacing direction="horizontal" size={4} />
<p
tabIndex={0}
aria-label={`킬링파트 구간 ${startMin}${startSec}초부터 ${endMin}${endSec}초`}
>
{toPlayingTimeText(start, end)}
</p>
<Spacing direction="horizontal" size={10} />
</TimeWrapper>
<ShareButton onClick={shareUrl}>
<Share src={link} alt="영상 링크 공유하기" />
</ShareButton>
</PartItemGrid>
);
};

const PartItemGrid = styled.li`
cursor: pointer;
display: grid;
grid-template:
'thumbnail title _' 26px
'thumbnail singer share' 26px
'thumbnail info share' 18px
/ 70px auto 26px;
column-gap: 8px;
width: 100%;
padding: 6px 10px;
color: ${({ theme: { color } }) => color.white};
text-align: start;
&:hover,
&:focus {
background-color: ${({ theme }) => theme.color.secondary};
}
`;

const SongTitle = styled.div`
overflow: hidden;
grid-area: title;
font-size: 16px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
`;

const Singer = styled.div`
overflow: hidden;
grid-area: singer;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
`;

const TimeWrapper = styled.div`
display: flex;
grid-area: info;
font-size: 14px;
font-weight: 700;
color: ${({ theme: { color } }) => color.primary};
letter-spacing: 1px;
`;

const ShareButton = styled.button`
grid-area: share;
width: 26px;
height: 26px;
`;

const Share = styled.img`
padding: 2px;
background-color: white;
border-radius: 50%;
`;

const Shook = styled.img`
width: 16px;
height: 18px;
border-radius: 50%;
`;

export default PartItem;
44 changes: 44 additions & 0 deletions frontend/src/features/member/components/PartList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { useEffect } from 'react';
import styled from 'styled-components';
import PartItem from './PartItem';
import type { LikeKillingPart } from './MyPartList';
import type { MyPageTab } from '../types/myPage';

interface PartListProps {
parts: LikeKillingPart[];
isShow: boolean;
tab: MyPageTab;
}

const PART_LIST_SCROLL_TOP = 180;

const PartList = ({ parts, isShow, tab }: PartListProps) => {
useEffect(() => {
if (window.scrollY > PART_LIST_SCROLL_TOP) {
window.scrollTo({ top: PART_LIST_SCROLL_TOP, behavior: 'smooth' });
}
}, [isShow]);

if (!isShow) {
return null;
}

return (
<PartListContainer>
{parts.map((part) => (
<PartItem key={part.partId} tab={tab} {...part} />
))}
</PartListContainer>
);
};

export default PartList;

const PartListContainer = styled.ol`
display: flex;
flex-direction: column;
gap: 12px;
align-items: flex-start;
width: 100%;
`;
10 changes: 10 additions & 0 deletions frontend/src/features/member/constants/introductions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export const introductions = [
'아무 노래나 일단 틀어',
'또 물보라를 일으켜',
'난 내가 말야, 스무살쯤엔 요절할 천재일줄만 알고',
'You make me feel special',
'우린 참 별나고 이상한 사이야',
'난 차라리 꽉 눌러붙을래, 날 재촉한다면',
'So lovely day so lovely Errday with you so lovely',
'Weight of the world on your shoulders',
] as const;
3 changes: 3 additions & 0 deletions frontend/src/features/member/remotes/memberParts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import fetcher from '@/shared/remotes';

export const deleteMemberParts = (partId: number) => fetcher(`/member-parts/${partId}`, 'DELETE');
5 changes: 5 additions & 0 deletions frontend/src/features/member/remotes/myPage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import fetcher from '@/shared/remotes';

export const getLikeParts = () => fetcher('/my-page/like-parts', 'GET');

export const getMyParts = () => fetcher('/my-page/my-parts', 'GET');
1 change: 1 addition & 0 deletions frontend/src/features/member/types/myPage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type MyPageTab = 'Like' | 'MyKillingPart';
7 changes: 7 additions & 0 deletions frontend/src/features/member/utils/getRandomIntroduction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { introductions } from '../constants/introductions';

const getRandomIntroduction = (index = Math.floor(Math.random() * introductions.length)) => {
return introductions[index];
};

export default getRandomIntroduction;
Loading

0 comments on commit 749ec05

Please sign in to comment.