diff --git a/frontend/src/assets/icon/pin.svg b/frontend/src/assets/icon/pin.svg new file mode 100644 index 00000000..60dfa418 --- /dev/null +++ b/frontend/src/assets/icon/pin.svg @@ -0,0 +1,2 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icon/play-stream.svg b/frontend/src/assets/icon/play-stream.svg new file mode 100644 index 00000000..090a75f9 --- /dev/null +++ b/frontend/src/assets/icon/play-stream.svg @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icon/remove.svg b/frontend/src/assets/icon/remove.svg new file mode 100644 index 00000000..802ca778 --- /dev/null +++ b/frontend/src/assets/icon/remove.svg @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/frontend/src/features/killingParts/components/RegisterPart.tsx b/frontend/src/features/killingParts/components/RegisterPart.tsx index dbe46970..0d8359b1 100644 --- a/frontend/src/features/killingParts/components/RegisterPart.tsx +++ b/frontend/src/features/killingParts/components/RegisterPart.tsx @@ -1,3 +1,4 @@ +import { useNavigate } from 'react-router-dom'; import styled from 'styled-components'; import { useAuthContext } from '@/features/auth/components/AuthProvider'; import useCollectingPartContext from '@/features/killingParts/hooks/useCollectingPartContext'; @@ -5,55 +6,50 @@ import { usePostKillingPart } from '@/features/killingParts/remotes/usePostKilli import useVideoPlayerContext from '@/features/youtube/hooks/useVideoPlayerContext'; import useModal from '@/shared/components/Modal/hooks/useModal'; import Modal from '@/shared/components/Modal/Modal'; -import useToastContext from '@/shared/components/Toast/hooks/useToastContext'; +import Spacing from '@/shared/components/Spacing'; import { toPlayingTimeText } from '@/shared/utils/convertTime'; -import copyClipboard from '@/shared/utils/copyClipBoard'; const RegisterPart = () => { const { isOpen, openModal, closeModal } = useModal(); - const { showToast } = useToastContext(); const { user } = useAuthContext(); - const { interval, partStartTime, songId, songVideoId } = useCollectingPartContext(); + const { interval, partStartTime, songId } = useCollectingPartContext(); const video = useVideoPlayerContext(); const { createKillingPart } = usePostKillingPart(); + const navigate = useNavigate(); // 현재 useMutation 훅이 response 객체를 리턴하지 않고 내부적으로 처리합니다. // 때문에 컴포넌트 단에서 createKillingPart 성공 여부에 따라 등록 완료 만료를 처리를 할 수 없어요! - // 현재 비로그인 시에 등록을 누르면 두 개의 모달이 뜹니다. + // 현재 비로그인 시에 등록을 누르면 두 개의 모달이 뜹니다.정 const submitKillingPart = async () => { video.pause(); await createKillingPart(songId, { startSecond: partStartTime, length: interval }); - openModal(); + navigate(-1); }; const voteTimeText = toPlayingTimeText(partStartTime, partStartTime + interval); - const copyPartVideoUrl = async () => { - await copyClipboard(`https://www.youtube.com/watch?v=${songVideoId}&t=${partStartTime}s`); - closeModal(); - showToast('클립보드에 영상링크가 복사되었습니다.'); - }; - return ( <> - + 등록 - {user?.nickname}님의 - 킬링파트 등록을 완료했습니다. + {user?.nickname}님의 파트 저장 - {voteTimeText} - 파트를 공유해 보세요😀 + + {voteTimeText} + + + 나만의 파트로 등록하시겠습니까? - 확인 + 취소 - - 공유하기 + + 등록 @@ -125,3 +121,11 @@ const ButtonContainer = styled.div` gap: 16px; width: 100%; `; + +const Part = styled.span` + background-color: ${({ theme: { color } }) => color.disabled}; + border-radius: 10px; + padding: 6px 11px; + letter-spacing: 1px; + color: white; +`; diff --git a/frontend/src/features/killingParts/components/VideoBadges.tsx b/frontend/src/features/killingParts/components/VideoBadges.tsx index 107212d4..3baba8cc 100644 --- a/frontend/src/features/killingParts/components/VideoBadges.tsx +++ b/frontend/src/features/killingParts/components/VideoBadges.tsx @@ -1,18 +1,31 @@ -import styled from 'styled-components'; +import styled, { css, keyframes } from 'styled-components'; import playIcon from '@/assets/icon/fill-play.svg'; import pauseIcon from '@/assets/icon/pause.svg'; +import pinIcon from '@/assets/icon/pin.svg'; +import playStreamIcon from '@/assets/icon/play-stream.svg'; +import removeIcon from '@/assets/icon/remove.svg'; import useCollectingPartContext from '@/features/killingParts/hooks/useCollectingPartContext'; +import usePin from '@/features/killingParts/hooks/usePin'; import useVideoPlayerContext from '@/features/youtube/hooks/useVideoPlayerContext'; import Flex from '@/shared/components/Flex/Flex'; import { toMinSecText } from '@/shared/utils/convertTime'; const VideoBadges = () => { const { partStartTime, isPlayingEntire, toggleEntirePlaying } = useCollectingPartContext(); + const { + pinList, + isPinListEmpty, + activePinIndex, + ref, + pinAnimationRef, + addPin, + deletePin, + playPin, + } = usePin(); const video = useVideoPlayerContext(); const partStartTimeText = toMinSecText(partStartTime); const isPaused = video.playerState === YT.PlayerState.PAUSED; - const videoPlay = () => { if (isPlayingEntire) { video.play(); @@ -25,19 +38,58 @@ const VideoBadges = () => { }; return ( - - {partStartTimeText} - - {'재생 - - - 전체 듣기 - - + <> + + + + {partStartTimeText} + + + 나만의 파트 임시 저장 + + + {'재생 + + + 전체 듣기 + + + + {!isPinListEmpty && ( + + 나만의 파트 임시 저장 삭제하기 + + )} + + {pinList.map((pin, index) => ( + + {pin.text} + + ))} + + + ); }; export default VideoBadges; +const PinFlex = styled(Flex)` + //overflow-x: scroll; + //position: relative; + width: 100%; +`; + +const PinInner = styled(Flex)` + overflow-x: scroll; + width: calc(100% - 44px); +`; + const Badge = styled.span<{ $isActive?: boolean }>` display: flex; align-items: center; @@ -48,16 +100,83 @@ const Badge = styled.span<{ $isActive?: boolean }>` padding: 0 10px; font-size: 14px; - color: ${({ theme: { color }, $isActive }) => ($isActive ? color.black : color.white)}; + color: ${({ theme: { color } }) => color.white}; text-align: center; background-color: ${({ theme: { color }, $isActive }) => - $isActive ? color.magenta200 : color.disabled}; + $isActive ? color.magenta700 : color.disabled}; border-radius: 40px; - transition: background-color 0.2s ease-in; + transition: + background-color 0.2s ease-in, + box-shadow 0.2s ease; + + &:active { + box-shadow: 0 0 0 1px inset white; + } @media (min-width: ${({ theme }) => theme.breakPoints.md}) { font-size: 16px; } `; + +const StartBadge = styled(Badge)` + margin-right: auto; + letter-spacing: 1px; +`; + +const slideFirstItem = keyframes` + from { + opacity: 0; + transform: translateX(-30px); + + } + to { + opacity: 1; + transform: translateX(0); + } +`; + +const slideRestItems = keyframes` + from { + transform: translateX(-15px); + } + to { + transform: translateX(0); + } +`; + +const PinBadge = styled(Badge)<{ $isActive?: boolean; $isNew?: boolean }>` + background-color: ${({ theme: { color }, $isActive }) => + $isActive ? color.magenta700 : color.disabledBackground}; + + z-index: ${({ $isActive }) => ($isActive ? 1 : 0)}; + opacity: ${({ $isActive }) => ($isActive ? 1 : 0.5)} + + border: none; + width: 50px; + white-space: nowrap; + + color: black; + font-size: 12px; + margin-right: 4px; + border-radius: 4px; + + transition: background-color 0.5s ease-in-out; + animation: ${({ $isNew }) => + $isNew + ? css` + ${slideFirstItem} 1s forwards + ` + : css` + ${slideRestItems} 0.5s forwards + `}; +`; + +const DeleteBadge = styled(Badge)` + border-radius: 50%; + height: 30px; + min-width: 30px; + padding: 0; + margin-right: 10px; +`; diff --git a/frontend/src/features/killingParts/components/VideoController.tsx b/frontend/src/features/killingParts/components/VideoController.tsx index 24726625..eba8bd41 100644 --- a/frontend/src/features/killingParts/components/VideoController.tsx +++ b/frontend/src/features/killingParts/components/VideoController.tsx @@ -6,7 +6,7 @@ import Flex from '@/shared/components/Flex/Flex'; const VideoController = () => { return ( - + 길이 선택 diff --git a/frontend/src/features/killingParts/components/VideoIntervalStepper.tsx b/frontend/src/features/killingParts/components/VideoIntervalStepper.tsx index 923a3dcb..0e7c721c 100644 --- a/frontend/src/features/killingParts/components/VideoIntervalStepper.tsx +++ b/frontend/src/features/killingParts/components/VideoIntervalStepper.tsx @@ -21,11 +21,11 @@ const StepperElementStyle = css` min-width: 50px; margin: 0; - padding: 4px 11px; font-weight: 700; text-align: center; + height: 30px; border: none; border-radius: 10px; `; @@ -36,6 +36,9 @@ const ControlButton = styled.button` background-color: ${({ theme: { color } }) => color.secondary}; font-size: 24px; + display: flex; + justify-content: center; + align-items: center; &:active { background-color: ${({ theme: { color } }) => color.disabled}; diff --git a/frontend/src/features/killingParts/hooks/usePin.ts b/frontend/src/features/killingParts/hooks/usePin.ts new file mode 100644 index 00000000..f2033269 --- /dev/null +++ b/frontend/src/features/killingParts/hooks/usePin.ts @@ -0,0 +1,76 @@ +import { useMemo, useRef, useState } from 'react'; +import useCollectingPartContext from '@/features/killingParts/hooks/useCollectingPartContext'; +import { toMinSecText } from '@/shared/utils/convertTime'; + +interface Pin { + partStartTime: number; + interval: number; + text: string; +} + +const usePin = () => { + const { partStartTime, interval, setPartStartTime, setInterval } = useCollectingPartContext(); + const [pinList, setPinList] = useState([]); + const ref = useRef(null); + + const activePinIndex = useMemo( + () => + pinList.findIndex((pin) => pin.partStartTime === partStartTime && pin.interval === interval), + [pinList, partStartTime, interval] + ); + + const isPinListEmpty = pinList.length === 0; + + const pinAnimationRef = useRef(1); + const refreshPinAnimation = () => { + pinAnimationRef.current += 1; + }; + + const addPin = () => { + const text = `${toMinSecText(partStartTime)}`; + + setPinList((prevTimeList) => [ + { + partStartTime, + interval, + text, + }, + ...prevTimeList.filter((pin) => pin.text !== text), + ]); + + if (ref.current) { + ref.current.scrollTo({ + left: 0, + behavior: 'smooth', + }); + } + + refreshPinAnimation(); + }; + + const deletePin = () => { + if (activePinIndex >= 0) { + setPinList(pinList.filter((_, index) => index !== activePinIndex)); + } else { + setPinList(pinList.slice(1)); + } + }; + + const playPin = (start: number, interval: number) => () => { + setPartStartTime(start); + setInterval(interval); + }; + + return { + pinList, + isPinListEmpty, + activePinIndex, + pinAnimationRef, + ref, + addPin, + deletePin, + playPin, + }; +}; + +export default usePin; diff --git a/frontend/src/features/killingParts/hooks/useWave.ts b/frontend/src/features/killingParts/hooks/useWave.ts index d92fdd09..89a8fe0e 100644 --- a/frontend/src/features/killingParts/hooks/useWave.ts +++ b/frontend/src/features/killingParts/hooks/useWave.ts @@ -68,7 +68,7 @@ const useWave = () => { setXPos(null); }; - useDebounceEffect(() => video.seekTo(partStartTime), [partStartTime], 150); + useDebounceEffect(() => video.seekTo(partStartTime), [partStartTime], 300); useDebounceEffect( () => { if (boxRef.current) { diff --git a/frontend/src/features/songs/components/CollectingPartProvider.tsx b/frontend/src/features/songs/components/CollectingPartProvider.tsx index e98eeca3..0448bd43 100644 --- a/frontend/src/features/songs/components/CollectingPartProvider.tsx +++ b/frontend/src/features/songs/components/CollectingPartProvider.tsx @@ -14,6 +14,7 @@ interface CollectingPartContextProps extends CollectingPartProviderProps { interval: number; isPlayingEntire: boolean; setPartStartTime: React.Dispatch>; + setInterval: React.Dispatch>; increasePartInterval: () => void; decreasePartInterval: () => void; toggleEntirePlaying: () => void; @@ -72,6 +73,7 @@ export const CollectingPartProvider = ({ songVideoId, isPlayingEntire, setPartStartTime, + setInterval, increasePartInterval, decreasePartInterval, toggleEntirePlaying,