diff --git a/frontend/src/assets/icon/trash.svg b/frontend/src/assets/icon/trash.svg new file mode 100644 index 000000000..c7f4afe42 --- /dev/null +++ b/frontend/src/assets/icon/trash.svg @@ -0,0 +1,59 @@ + + + + + + image/svg+xml + + + + + + + + + + diff --git a/frontend/src/features/member/components/MyPartList.tsx b/frontend/src/features/member/components/MyPartList.tsx new file mode 100644 index 000000000..fb95df958 --- /dev/null +++ b/frontend/src/features/member/components/MyPartList.tsx @@ -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 & { + songId: number; + partId: number; + }; + +const MyPartList = () => { + const [tab, setTab] = useState('Like'); + + const { data: likes } = useFetch(getLikeParts); + const { data: myParts } = useFetch(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) => { + if (event.key === 'Enter') { + setTab(tab); + } + }; + + return ( + <> + + {partTabItems.map((option) => ( + setTab(option.tab)} + onKeyDown={pressEnterChangeTab(option.tab)} + tabIndex={0} + > + {option.title} + + ))} + + + + + {partTabItems.map((option) => ( + + ))} + > + ); +}; + +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; +`; diff --git a/frontend/src/features/member/components/PartItem.tsx b/frontend/src/features/member/components/PartItem.tsx new file mode 100644 index 000000000..03ba600ca --- /dev/null +++ b/frontend/src/features/member/components/PartItem.tsx @@ -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 ( + + + {title} + {singer} + + + + + {toPlayingTimeText(start, end)} + + + + + + + + ); +}; + +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; diff --git a/frontend/src/features/member/components/PartList.tsx b/frontend/src/features/member/components/PartList.tsx new file mode 100644 index 000000000..6b3a45fca --- /dev/null +++ b/frontend/src/features/member/components/PartList.tsx @@ -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 ( + + {parts.map((part) => ( + + ))} + + ); +}; + +export default PartList; + +const PartListContainer = styled.ol` + display: flex; + flex-direction: column; + gap: 12px; + align-items: flex-start; + + width: 100%; +`; diff --git a/frontend/src/features/member/constants/introductions.ts b/frontend/src/features/member/constants/introductions.ts new file mode 100644 index 000000000..854ec3376 --- /dev/null +++ b/frontend/src/features/member/constants/introductions.ts @@ -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; diff --git a/frontend/src/features/member/remotes/memberParts.ts b/frontend/src/features/member/remotes/memberParts.ts new file mode 100644 index 000000000..28ef36132 --- /dev/null +++ b/frontend/src/features/member/remotes/memberParts.ts @@ -0,0 +1,3 @@ +import fetcher from '@/shared/remotes'; + +export const deleteMemberParts = (partId: number) => fetcher(`/member-parts/${partId}`, 'DELETE'); diff --git a/frontend/src/features/member/remotes/myPage.ts b/frontend/src/features/member/remotes/myPage.ts new file mode 100644 index 000000000..b0ab55df8 --- /dev/null +++ b/frontend/src/features/member/remotes/myPage.ts @@ -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'); diff --git a/frontend/src/features/member/types/myPage.ts b/frontend/src/features/member/types/myPage.ts new file mode 100644 index 000000000..f82ab8ee5 --- /dev/null +++ b/frontend/src/features/member/types/myPage.ts @@ -0,0 +1 @@ +export type MyPageTab = 'Like' | 'MyKillingPart'; diff --git a/frontend/src/features/member/utils/getRandomIntroduction.ts b/frontend/src/features/member/utils/getRandomIntroduction.ts new file mode 100644 index 000000000..4994465d3 --- /dev/null +++ b/frontend/src/features/member/utils/getRandomIntroduction.ts @@ -0,0 +1,7 @@ +import { introductions } from '../constants/introductions'; + +const getRandomIntroduction = (index = Math.floor(Math.random() * introductions.length)) => { + return introductions[index]; +}; + +export default getRandomIntroduction; diff --git a/frontend/src/features/songs/components/CollectionCarousel.tsx b/frontend/src/features/songs/components/CollectionCarousel.tsx index 81c214f25..86e286ecc 100644 --- a/frontend/src/features/songs/components/CollectionCarousel.tsx +++ b/frontend/src/features/songs/components/CollectionCarousel.tsx @@ -31,18 +31,16 @@ const CollectionCarousel = ({ children }: CarouselProps) => { }, [currentIndex, numberOfItems]); return ( - <> - - - {children} - - - {Array.from({ length: numberOfItems }, (_, idx) => ( - - ))} - - - > + + + {children} + + + {Array.from({ length: numberOfItems }, (_, idx) => ( + + ))} + + ); }; diff --git a/frontend/src/features/songs/components/KillingPartInterface.tsx b/frontend/src/features/songs/components/KillingPartInterface.tsx index b2aec0997..449b7b9b4 100644 --- a/frontend/src/features/songs/components/KillingPartInterface.tsx +++ b/frontend/src/features/songs/components/KillingPartInterface.tsx @@ -11,13 +11,14 @@ import type { KillingPart, SongDetail } from '@/shared/types/song'; interface KillingPartInterfaceProps { killingParts: SongDetail['killingParts']; + memberPart: SongDetail['memberPart']; songId: number; } const DEFAULT_PART_ID = -1; -const KillingPartInterface = ({ killingParts, songId }: KillingPartInterfaceProps) => { - const [nowPlayingTrack, setNowPlayingTrack] = useState(DEFAULT_PART_ID); +const KillingPartInterface = ({ killingParts, songId, memberPart }: KillingPartInterfaceProps) => { + const [nowPlayingTrack, setNowPlayingTrack] = useState(DEFAULT_PART_ID); const [commentsPartId, setCommentsPartId] = useState(DEFAULT_PART_ID); const [isRepeat, setIsRepeat] = useState(false); const { videoPlayer, playerState, seekTo, pause } = useVideoPlayerContext(); @@ -34,10 +35,16 @@ const KillingPartInterface = ({ killingParts, songId }: KillingPartInterfaceProp } }, [videoPlayer, playerState]); + const trackList = [...killingParts, memberPart].map((part, i) => ({ part, order: i + 1 })); + useEffect(() => { - const part = killingParts.find((part) => part.id === nowPlayingTrack); - if (!part || !videoPlayer.current) return; + if (nowPlayingTrack === DEFAULT_PART_ID) return; + + const track = trackList.find(({ order }) => order === nowPlayingTrack); + + if (!track || !videoPlayer.current) return; + const { part } = track; const partLength = (part.end - part.start) * 1000; const remainingTime = partLength - countedTime * 1000; @@ -68,16 +75,7 @@ const KillingPartInterface = ({ killingParts, songId }: KillingPartInterfaceProp window.clearTimeout(timeoutId2); window.clearInterval(intervalIds); }; - }, [ - killingParts, - isRepeat, - nowPlayingTrack, - videoPlayer, - pause, - resetTimer, - seekTo, - countedTime, - ]); + }, [trackList, isRepeat, nowPlayingTrack, videoPlayer, pause, resetTimer, seekTo, countedTime]); useEffect(() => { resetTimer(); @@ -140,6 +138,7 @@ const KillingPartInterface = ({ killingParts, songId }: KillingPartInterfaceProp { return ( >; + setNowPlayingTrack: React.Dispatch>; setCommentsPartId: React.Dispatch>; + isMyKillingPart?: boolean; + hideMyPart?: () => void; } const KillingPartTrack = ({ + order, killingPart: { id: partId, rank, start, end, likeCount, likeStatus }, songId, isNowPlayingTrack, setNowPlayingTrack, setCommentsPartId, + isMyKillingPart, + hideMyPart, }: KillingPartTrackProps) => { const { showToast } = useToastContext(); - const { seekTo, pause, playerState } = useVideoPlayerContext(); + const { seekTo, pause, playerState, videoPlayer } = useVideoPlayerContext(); const { calculatedLikeCount, heartIcon, toggleKillingPartLikes } = useKillingPartLikes({ likeCount, likeStatus, @@ -42,7 +52,16 @@ const KillingPartTrack = ({ partId, }); const { countedTime: currentPlayTime } = useTimerContext(); - const { isOpen, closeModal, openModal } = useModal(); + const { + isOpen: isLoginModalOpen, + closeModal: closeLoginModal, + openModal: openLoginModal, + } = useModal(); + const { + isOpen: isMyPartModal, + closeModal: closeMyPartModal, + openModal: openMyPartModal, + } = useModal(); const { user } = useAuthContext(); const isLoggedIn = user !== null; @@ -61,6 +80,17 @@ const KillingPartTrack = ({ showToast('영상 링크가 복사되었습니다.'); }; + const copyMyPartUrl = async () => { + sendGAEvent({ + action: GA_ACTIONS.COPY_URL, + category: GA_CATEGORIES.SONG_DETAIL, + memberId: user?.memberId, + }); + + await copyClipboard(`${videoPlayer.current?.getVideoUrl()}&t=${start}s`); + showToast('영상 링크가 복사되었습니다.'); + }; + const getPlayIcon = useCallback(() => { if (!isNowPlayingTrack || playerState === YT.PlayerState.PAUSED) { return emptyPlayIcon; @@ -78,8 +108,13 @@ const KillingPartTrack = ({ const playTrack = () => { seekTo(start); - setNowPlayingTrack(partId); - setCommentsPartId(partId); + setNowPlayingTrack(order); + + if (order !== 4) { + setCommentsPartId(partId); + } else { + setCommentsPartId(-1); + } }; const stopTrack = () => { @@ -111,18 +146,31 @@ const KillingPartTrack = ({ toggleKillingPartLikes(); }; + const { mutateData: deleteMemberPart } = useMutation(() => deleteMemberParts(partId)); + + const deleteMyPart = async () => { + if (!hideMyPart) return; + + await deleteMemberPart(); + + hideMyPart(); + pause(); + closeMyPartModal(); + showToast('내 파트가 삭제되었습니다.'); + }; + return ( - {ordinalRank} + {isMyKillingPart ? 'MY' : ordinalRank} {playingTime} - - - {`${calculatedLikeCount} Likes`} - - - - Share - + {isMyKillingPart ? ( + <> + + + Delete + + + + Share + + > + ) : ( + <> + + + {`${calculatedLikeCount} Likes`} + + + + Share + + > + )} {isNowPlayingTrack && ( )} + + + ); @@ -220,6 +290,10 @@ const ButtonWithIcon = css` width: 44px; `; +const DeleteButton = styled.button` + ${ButtonWithIcon} +`; + const LikeButton = styled.button` ${ButtonWithIcon} `; diff --git a/frontend/src/features/songs/components/KillingPartTrackList.stories.tsx b/frontend/src/features/songs/components/KillingPartTrackList.stories.tsx index e3fc9a9e5..15e625634 100644 --- a/frontend/src/features/songs/components/KillingPartTrackList.stories.tsx +++ b/frontend/src/features/songs/components/KillingPartTrackList.stories.tsx @@ -1,10 +1,12 @@ import { useState } from 'react'; import { VideoPlayerProvider } from '@/features/youtube/components/VideoPlayerProvider'; +import TimerProvider from '@/shared/components/Timer/TimerProvider'; import ToastProvider from '@/shared/components/Toast/ToastProvider'; import KillingPartTrackList from './KillingPartTrackList'; import type { KillingPart } from '@/shared/types/song'; import type { Meta, StoryObj } from '@storybook/react'; +// FIXME: 재생시 `YT is not defined` 에러 발생 const meta = { component: KillingPartTrackList, title: 'KillingPartTrackList', @@ -13,7 +15,10 @@ const meta = { return ( - + {/* FIXME: time prop을 KillingPartTrack 스토리북 컴포넌트를 보고 임의로 똑같이 넣었습니다. */} + + + ); @@ -60,6 +65,18 @@ const killingPart3: KillingPart = { partLength: 10, }; +const memberPart: KillingPart = { + id: 4, + rank: 3, + voteCount: 0, + start: 70, + end: 80, + partVideoUrl: 'https://youtu.be/ArmDp-zijuc?start=105&end=115', + likeCount: 12, + likeStatus: false, + partLength: 10, +}; + const KillingPartTrackListWithHooks = () => { const [nowPlayingTrack, setNowPlayingTrack] = useState(-1); // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -67,6 +84,7 @@ const KillingPartTrackListWithHooks = () => { return ( >; + setNowPlayingTrack: React.Dispatch>; setCommentsPartId: React.Dispatch>; } const KillingPartTrackList = ({ killingParts, + memberPart, songId, nowPlayingTrack, setNowPlayingTrack, setCommentsPartId, }: KillingPartTrackListProps) => { + const [myPartDetail, setMyPartDetail] = useState(memberPart); + + const { user } = useAuthContext(); + const navigate = useNavigate(); + + const isLoggedIn = !!user; + const goToPartCollectingPage = () => navigate(`/${ROUTE_PATH.COLLECT}/${songId}`); + + const { isOpen, openModal, closeModal } = useModal(); + + const hideMyPart = () => setMyPartDetail(null); + return ( - {killingParts.map((killingPart) => { - const { id } = killingPart; - const isNowPlayingTrack = id === nowPlayingTrack; - - return ( - - ); - })} + {killingParts.map((killingPart, i) => ( + + ))} + + {myPartDetail ? ( + + ) : ( + + + My Part + + )} + + ); }; @@ -45,3 +84,19 @@ export const TrackList = styled.div` flex-direction: column; gap: 10px; `; + +const PartRegisterButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + + height: 60px; + padding: 0 12px; + + background-color: ${({ theme: { color } }) => color.secondary}; + border-radius: 4px; + + @media (max-width: ${({ theme }) => theme.breakPoints.xxs}) { + height: 46px; + } +`; diff --git a/frontend/src/features/songs/components/MyPartModal.tsx b/frontend/src/features/songs/components/MyPartModal.tsx new file mode 100644 index 000000000..ae472ddc2 --- /dev/null +++ b/frontend/src/features/songs/components/MyPartModal.tsx @@ -0,0 +1,57 @@ +import styled from 'styled-components'; +import Modal from '@/shared/components/Modal/Modal'; +import Spacing from '@/shared/components/Spacing'; + +interface MyPartModalProps { + isOpen: boolean; + closeModal: () => void; + onDelete: () => void; +} + +const MyPartModal = ({ isOpen, closeModal, onDelete }: MyPartModalProps) => { + return ( + + 정말 삭제하시겠습니까? + + + + 취소 + + + 삭제 + + + + ); +}; + +const ModalTitle = styled.h3``; + +const Button = styled.button` + cursor: pointer; + + height: 36px; + + color: ${({ theme: { color } }) => color.white}; + + border: none; + border-radius: 10px; +`; + +const CancelButton = styled(Button)` + flex: 1; + background-color: ${({ theme: { color } }) => color.secondary}; +`; + +const DeleteButton = styled(Button)` + flex: 1; + background-color: ${({ theme: { color } }) => color.primary}; +`; + +const ButtonContainer = styled.div` + display: flex; + gap: 16px; + width: 100%; +`; + +export default MyPartModal; diff --git a/frontend/src/features/songs/components/SongDetailItem.tsx b/frontend/src/features/songs/components/SongDetailItem.tsx index b69d40b14..8097764f9 100644 --- a/frontend/src/features/songs/components/SongDetailItem.tsx +++ b/frontend/src/features/songs/components/SongDetailItem.tsx @@ -17,7 +17,7 @@ import type { SongDetail } from '@/shared/types/song'; interface SongDetailItemProps extends SongDetail {} const SongDetailItem = forwardRef( - ({ id, killingParts, singer, title, songVideoId, albumCoverUrl }, ref) => { + ({ id, killingParts, singer, title, songVideoId, albumCoverUrl, memberPart }, ref) => { const navigate = useNavigate(); const { genre } = useValidParams(); @@ -61,7 +61,11 @@ const SongDetailItem = forwardRef( - + diff --git a/frontend/src/features/songs/constants/genres.ts b/frontend/src/features/songs/constants/genres.ts index 04b09b261..a885de3f5 100644 --- a/frontend/src/features/songs/constants/genres.ts +++ b/frontend/src/features/songs/constants/genres.ts @@ -15,6 +15,5 @@ const GENRES = { EDM: 'EDM', ETC: '기타', } as const; -` `; export default GENRES; diff --git a/frontend/src/mocks/fixtures/likeParts.json b/frontend/src/mocks/fixtures/likeParts.json new file mode 100644 index 000000000..c6553970b --- /dev/null +++ b/frontend/src/mocks/fixtures/likeParts.json @@ -0,0 +1,101 @@ +[ + { + "songId": 1, + "title": "Super Shy", + "singer": "New Jeans", + "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/120/quality/80/optimize", + "partId": 11, + "start": 10, + "end": 20 + }, + { + "songId": 2, + "title": "제목이 너무 길다면 어떻게 할 건데? 어떻게 할 건데? 어떻게 할 건데? 어떻게 할 건데? 어떻게 할 건데?", + "singer": "가수 이름도 너무 길다면 어떻게 할 건데? 어떻게 할 건데? 어떻게 할 건데? 어떻게 할 건데? 어떻게 할 건데?", + "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/120/quality/80/optimize", + "partId": 12, + "start": 5, + "end": 15 + }, + { + "songId": 3, + "title": "Super Shy", + "singer": "New Jeans", + "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/120/quality/80/optimize", + "partId": 13, + "start": 66, + "end": 71 + }, + { + "songId": 4, + "title": "Super Shy", + "singer": "New Jeans", + "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/120/quality/80/optimize", + "partId": 14, + "start": 100, + "end": 115 + }, + { + "songId": 5, + "title": "Super Shy", + "singer": "New Jeans", + "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/120/quality/80/optimize", + "partId": 15, + "start": 72, + "end": 82 + }, + { + "songId": 6, + "title": "Super Shy", + "singer": "New Jeans", + "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/120/quality/80/optimize", + "partId": 16, + "start": 66, + "end": 76 + }, + { + "songId": 7, + "title": "Super Shy", + "singer": "New Jeans", + "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/120/quality/80/optimize", + "partId": 16, + "start": 66, + "end": 76 + }, + { + "songId": 8, + "title": "Super Shy", + "singer": "New Jeans", + "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/120/quality/80/optimize", + "partId": 16, + "start": 66, + "end": 76 + }, + { + "songId": 9, + "title": "Super Shy", + "singer": "New Jeans", + "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/120/quality/80/optimize", + "partId": 16, + "start": 66, + "end": 76 + }, + { + "songId": 10, + "title": "Super Shy", + "singer": "New Jeans", + "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/120/quality/80/optimize", + "partId": 16, + "start": 66, + "end": 76 + }, + { + "songId": 11, + "title": "Super Shy", + "singer": "New Jeans", + "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/120/quality/80/optimize", + "partId": 16, + "start": 66, + "end": 76 + } +] diff --git a/frontend/src/mocks/fixtures/myParts.json b/frontend/src/mocks/fixtures/myParts.json new file mode 100644 index 000000000..b2d25df18 --- /dev/null +++ b/frontend/src/mocks/fixtures/myParts.json @@ -0,0 +1,133 @@ +[ + { + "songId": 1, + "title": "후라이의 꿈", + "singer": "AKMU (악뮤)", + "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/113/09/190/11309190_20230818161008_500.jpg/melon/resize/120/quality/80/optimize", + "partId": 11, + "start": 1, + "end": 11, + "songVideoId": "3kGAlp_PNUg" + }, + { + "songId": 2, + "title": "제목이 너무 길다면 어떻게 할 건데? 어떻게 할 건데? 어떻게 할 건데? 어떻게 할 건데? 어떻게 할 건데?", + "singer": "가수 이름도 너무 길다면 어떻게 할 건데? 어떻게 할 건데? 어떻게 할 건데? 어떻게 할 건데? 어떻게 할 건데?", + "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/113/09/190/11309190_20230818161008_500.jpg/melon/resize/120/quality/80/optimize", + "partId": 12, + "start": 13, + "end": 23, + "songVideoId": "3kGAlp_PNUg" + }, + { + "songId": 3, + "title": "후라이의 꿈", + "singer": "AKMU (악뮤)", + "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/113/09/190/11309190_20230818161008_500.jpg/melon/resize/120/quality/80/optimize", + "partId": 13, + "start": 36, + "end": 46, + "songVideoId": "3kGAlp_PNUg" + }, + { + "songId": 4, + "title": "후라이의 꿈", + "singer": "AKMU (악뮤)", + "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/113/09/190/11309190_20230818161008_500.jpg/melon/resize/120/quality/80/optimize", + "partId": 14, + "start": 77, + "end": 87, + "songVideoId": "3kGAlp_PNUg" + }, + { + "songId": 5, + "title": "후라이의 꿈", + "singer": "AKMU (악뮤)", + "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/113/09/190/11309190_20230818161008_500.jpg/melon/resize/120/quality/80/optimize", + "partId": 15, + "start": 102, + "end": 112, + "songVideoId": "3kGAlp_PNUg" + }, + { + "songId": 6, + "title": "후라이의 꿈", + "singer": "AKMU (악뮤)", + "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/113/09/190/11309190_20230818161008_500.jpg/melon/resize/120/quality/80/optimize", + "partId": 16, + "start": 190, + "end": 200, + "songVideoId": "3kGAlp_PNUg" + }, + { + "songId": 7, + "title": "후라이의 꿈", + "singer": "AKMU (악뮤)", + "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/113/09/190/11309190_20230818161008_500.jpg/melon/resize/120/quality/80/optimize", + "partId": 16, + "start": 190, + "end": 200, + "songVideoId": "3kGAlp_PNUg" + }, + + { + "songId": 8, + "title": "후라이의 꿈", + "singer": "AKMU (악뮤)", + "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/113/09/190/11309190_20230818161008_500.jpg/melon/resize/120/quality/80/optimize", + "partId": 16, + "start": 190, + "end": 200, + "songVideoId": "3kGAlp_PNUg" + }, + { + "songId": 9, + "title": "후라이의 꿈", + "singer": "AKMU (악뮤)", + "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/113/09/190/11309190_20230818161008_500.jpg/melon/resize/120/quality/80/optimize", + "partId": 16, + "start": 190, + "end": 200, + "songVideoId": "3kGAlp_PNUg" + }, + { + "songId": 10, + "title": "후라이의 꿈", + "singer": "AKMU (악뮤)", + "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/113/09/190/11309190_20230818161008_500.jpg/melon/resize/120/quality/80/optimize", + "partId": 16, + "start": 190, + "end": 200, + "songVideoId": "3kGAlp_PNUg" + }, + { + "songId": 11, + "title": "후라이의 꿈", + "singer": "AKMU (악뮤)", + "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/113/09/190/11309190_20230818161008_500.jpg/melon/resize/120/quality/80/optimize", + "partId": 16, + "start": 190, + "end": 200, + "songVideoId": "3kGAlp_PNUg" + }, + { + "songId": 12, + "title": "후라이의 꿈", + "singer": "AKMU (악뮤)", + "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/113/09/190/11309190_20230818161008_500.jpg/melon/resize/120/quality/80/optimize", + "partId": 16, + "start": 190, + "end": 200, + "songVideoId": "3kGAlp_PNUg" + }, + { + "songId": 13, + "title": "후라이의 꿈", + "singer": "AKMU (악뮤)", + "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/113/09/190/11309190_20230818161008_500.jpg/melon/resize/120/quality/80/optimize", + "partId": 16, + "start": 190, + "end": 200, + "songVideoId": "3kGAlp_PNUg" + } +] diff --git a/frontend/src/mocks/fixtures/songEntries.json b/frontend/src/mocks/fixtures/songEntries.json index 568dc6cce..44c78a6e2 100644 --- a/frontend/src/mocks/fixtures/songEntries.json +++ b/frontend/src/mocks/fixtures/songEntries.json @@ -439,7 +439,17 @@ "partLength": 10, "likeStatus": false } - ] + ], + "memberPart": { + "id": 70, + "rank": 1, + "likeCount": 0, + "start": 22, + "end": 32, + "partVideoUrl": "https://youtu.be/haCpjUXIhrI?start=30&end=40", + "partLength": 10, + "likeStatus": true + } }, "nextSongs": [ { diff --git a/frontend/src/mocks/handlers/memberHandlers.ts b/frontend/src/mocks/handlers/memberHandlers.ts index c74897856..c82b19660 100644 --- a/frontend/src/mocks/handlers/memberHandlers.ts +++ b/frontend/src/mocks/handlers/memberHandlers.ts @@ -1,75 +1,16 @@ import { rest } from 'msw'; +import likeParts from '@/mocks/fixtures/likeParts.json'; +import myParts from '@/mocks/fixtures/myParts.json'; const { BASE_URL } = process.env; const memberHandlers = [ - rest.get(`${BASE_URL}/my-page`, (req, res, ctx) => { - return res( - ctx.json([ - { - songId: 1, - title: 'Super Shy', - singer: 'New Jeans', - albumCoverUrl: - 'https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/120/quality/80/optimize', - partId: 11, - start: 10, - end: 20, - }, - { - songId: 2, - title: - '제목이 너무 길다면 어떻게 할 건데? 어떻게 할 건데? 어떻게 할 건데? 어떻게 할 건데? 어떻게 할 건데?', - singer: - '가수 이름도 너무 길다면 어떻게 할 건데? 어떻게 할 건데? 어떻게 할 건데? 어떻게 할 건데? 어떻게 할 건데?', - albumCoverUrl: - 'https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/120/quality/80/optimize', - partId: 12, - start: 5, - end: 15, - }, - { - songId: 3, - title: 'Super Shy', - singer: 'New Jeans', - albumCoverUrl: - 'https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/120/quality/80/optimize', - partId: 13, - start: 66, - end: 71, - }, - { - songId: 4, - title: 'Super Shy', - singer: 'New Jeans', - albumCoverUrl: - 'https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/120/quality/80/optimize', - partId: 14, - start: 100, - end: 115, - }, - { - songId: 5, - title: 'Super Shy', - singer: 'New Jeans', - albumCoverUrl: - 'https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/120/quality/80/optimize', - partId: 15, - start: 72, - end: 82, - }, - { - songId: 6, - title: 'Super Shy', - singer: 'New Jeans', - albumCoverUrl: - 'https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/120/quality/80/optimize', - partId: 16, - start: 66, - end: 76, - }, - ]) - ); + rest.get(`${BASE_URL}/my-page/like-parts`, (req, res, ctx) => { + return res(ctx.json(likeParts)); + }), + + rest.get(`${BASE_URL}/my-page/my-parts`, (req, res, ctx) => { + return res(ctx.json(myParts)); }), ]; diff --git a/frontend/src/mocks/handlers/songsHandlers.ts b/frontend/src/mocks/handlers/songsHandlers.ts index 769762387..8b4363840 100644 --- a/frontend/src/mocks/handlers/songsHandlers.ts +++ b/frontend/src/mocks/handlers/songsHandlers.ts @@ -57,6 +57,10 @@ const songsHandlers = [ rest.get(`${BASE_URL}/voting-songs`, (req, res, ctx) => { return res(ctx.status(200), ctx.json(votingSongs)); }), + + rest.delete(`${BASE_URL}/member-parts/:partId`, (req, res, ctx) => { + return res(ctx.status(204)); + }), ]; export default songsHandlers; diff --git a/frontend/src/pages/MyPage.tsx b/frontend/src/pages/MyPage.tsx index 8824e2fad..45ab0a03b 100644 --- a/frontend/src/pages/MyPage.tsx +++ b/frontend/src/pages/MyPage.tsx @@ -1,42 +1,18 @@ 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 shookshook from '@/assets/icon/shookshook.svg'; import { useAuthContext } from '@/features/auth/components/AuthProvider'; -import Thumbnail from '@/features/songs/components/Thumbnail'; -import Flex from '@/shared/components/Flex'; +import MyPartList from '@/features/member/components/MyPartList'; +import getRandomIntroduction from '@/features/member/utils/getRandomIntroduction'; +import Flex from '@/shared/components/Flex/Flex'; import Spacing from '@/shared/components/Spacing'; import SRHeading from '@/shared/components/SRHeading'; -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 useFetch from '@/shared/hooks/useFetch'; -import fetcher from '@/shared/remotes'; -import { secondsToMinSec, toPlayingTimeText } from '@/shared/utils/convertTime'; -import copyClipboard from '@/shared/utils/copyClipBoard'; -import type { KillingPart, SongDetail } from '@/shared/types/song'; - -const { BASE_URL } = process.env; - -const introductions = [ - '아무 노래나 일단 틀어', - '또 물보라를 일으켜', - '난 내가 말야, 스무살쯤엔 요절할 천재일줄만 알고', - 'You make me feel special', - '우린 참 별나고 이상한 사이야', -]; - -type LikeKillingPart = Pick & - Pick & { - songId: number; - partId: number; - }; const MyPage = () => { const { user, logout } = useAuthContext(); - const { data: likes } = useFetch(() => fetcher('/my-page', 'get')); const navigate = useNavigate(); const logoutRedirect = () => { @@ -59,52 +35,34 @@ const MyPage = () => { navigate(`/${ROUTE_PATH.EDIT_PROFILE}`); }; - if (!likes) return null; - return ( 마이 페이지 - + - {user?.nickname} + {user?.nickname ?? 'shook'} - {introductions[Math.floor(Math.random() * introductions.length)]} + {getRandomIntroduction()} - - + + + + + - + - + 프로필 편집 로그아웃 - + - + - 좋아요한 킬링파트 {likes.length.toLocaleString('ko-KR')}개 + - - - {likes.map(({ songId, title, singer, albumCoverUrl, partId, start, end }, i) => { - return ( - - - - ); - })} - ); }; @@ -131,14 +89,8 @@ const Box = styled.div` width: 100%; `; -const Li = styled.li` - width: 100%; - padding: 0 10px; - - &:hover, - &:focus { - background-color: ${({ theme }) => theme.color.secondary}; - } +const ProfileFlex = styled(Flex)` + height: 108px; `; const Title = styled.h2` @@ -148,23 +100,15 @@ const Title = styled.h2` color: white; `; -const PopularSongList = styled.ol` - display: flex; - flex-direction: column; - gap: 12px; - align-items: flex-start; - - width: 100%; -`; +const Introduction = styled(Box)` + overflow: hidden; + display: -webkit-box; -const SpaceBetween = styled(Flex)` - justify-content: space-between; -`; + text-overflow: ellipsis; + word-break: break-word; -const Shook = styled.img` - width: 16px; - height: 18px; - border-radius: 50%; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; `; const Avatar = styled.img` @@ -182,118 +126,3 @@ const Button = styled.button` border: 1.6px solid ${({ theme }) => theme.color.secondary}; border-radius: 12px; `; - -const Subtitle = styled.div` - width: 100%; - height: 36px; - font-size: 18px; - border-bottom: 1px solid ${({ theme }) => theme.color.white}; -`; - -type LikePartItemProps = LikeKillingPart & { - rank: number; -}; - -const LikePartItem = ({ songId, albumCoverUrl, title, singer, start, end }: LikePartItemProps) => { - 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, - }); - - copyClipboard(`${BASE_URL?.replace('/api', '')}/songs/${songId}/ALL`); - showToast('클립보드에 영상링크가 복사되었습니다.'); - }; - - const goToListenSong = () => { - navigate(`/songs/${songId}/ALL`); - }; - - const { minute: startMin, second: startSec } = secondsToMinSec(start); - const { minute: endMin, second: endSec } = secondsToMinSec(end); - - return ( - - - {title} - {singer} - - - - - {toPlayingTimeText(start, end)} - - - - - - - - ); -}; - -const Grid = styled.button` - 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 0; - - color: ${({ theme: { color } }) => color.white}; - text-align: start; -`; - -const SongTitle = styled.div` - overflow: hidden; - grid-area: title; - - font-size: 16px; - font-weight: 800; - 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%; -`; diff --git a/frontend/src/shared/types/song.ts b/frontend/src/shared/types/song.ts index b5e080184..5eb81d5a7 100644 --- a/frontend/src/shared/types/song.ts +++ b/frontend/src/shared/types/song.ts @@ -12,6 +12,7 @@ export interface SongDetail { songVideoId: string; albumCoverUrl: string; killingParts: KillingPart[]; + memberPart: KillingPart; } export interface KillingPart {
+ {toPlayingTimeText(start, end)} +
- {toPlayingTimeText(start, end)} -