Skip to content

Commit

Permalink
Merge branch 'feat/#499' into myPartQA
Browse files Browse the repository at this point in the history
  • Loading branch information
ukkodeveloper committed Oct 18, 2023
2 parents 2538a15 + 1f22eca commit 2f445d7
Show file tree
Hide file tree
Showing 10 changed files with 249 additions and 37 deletions.
2 changes: 2 additions & 0 deletions frontend/src/assets/icon/pin.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions frontend/src/assets/icon/play-stream.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions frontend/src/assets/icon/remove.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
44 changes: 24 additions & 20 deletions frontend/src/features/killingParts/components/RegisterPart.tsx
Original file line number Diff line number Diff line change
@@ -1,59 +1,55 @@
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';
import { usePostKillingPart } from '@/features/killingParts/remotes/usePostKillingPart';
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 (
<>
<RegisterButton type="submit" onClick={submitKillingPart}>
<RegisterButton type="submit" onClick={openModal}>
등록
</RegisterButton>
<Modal isOpen={isOpen} closeModal={closeModal}>
<ModalTitle>
<TitleColumn>{user?.nickname}님의</TitleColumn>
<TitleColumn>킬링파트 등록을 완료했습니다.</TitleColumn>
<TitleColumn>{user?.nickname}님의 파트 저장</TitleColumn>
</ModalTitle>
<ModalContent>
<Message>{voteTimeText}</Message>
<Message>파트를 공유해 보세요😀</Message>
<Message>
<Part>{voteTimeText}</Part>
</Message>
<Spacing direction="vertical" size={6} />
<Message>나만의 파트로 등록하시겠습니까?</Message>
</ModalContent>
<ButtonContainer>
<Confirm type="button" onClick={closeModal}>
확인
취소
</Confirm>
<Share type="button" onClick={copyPartVideoUrl}>
공유하기
<Share type="button" onClick={submitKillingPart}>
등록
</Share>
</ButtonContainer>
</Modal>
Expand Down Expand Up @@ -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;
`;
147 changes: 133 additions & 14 deletions frontend/src/features/killingParts/components/VideoBadges.tsx
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -25,19 +38,58 @@ const VideoBadges = () => {
};

return (
<Flex $gap={14} $justify="flex-end">
<Badge>{partStartTimeText}</Badge>
<Badge as="button" type="button" onClick={isPaused ? videoPlay : videoPause}>
<img src={isPaused ? playIcon : pauseIcon} alt={'재생 혹은 정지'} />
</Badge>
<Badge as="button" type="button" $isActive={isPlayingEntire} onClick={toggleEntirePlaying}>
전체 듣기
</Badge>
</Flex>
<>
<Flex $gap={14} $justify="flex-end">
<StartBadge>
<img src={playStreamIcon} style={{ marginRight: '4px' }} alt="" />
{partStartTimeText}
</StartBadge>
<Badge as="button" onClick={addPin} $isActive={!isPinListEmpty}>
<img src={pinIcon} alt="나만의 파트 임시 저장" />
</Badge>
<Badge as="button" type="button" onClick={isPaused ? videoPlay : videoPause}>
<img src={isPaused ? playIcon : pauseIcon} alt={'재생 혹은 정지'} />
</Badge>
<Badge as="button" type="button" $isActive={isPlayingEntire} onClick={toggleEntirePlaying}>
전체 듣기
</Badge>
</Flex>
<PinFlex $gap={4} ref={ref}>
{!isPinListEmpty && (
<DeleteBadge as="button" onClick={deletePin}>
<img src={removeIcon} alt="나만의 파트 임시 저장 삭제하기" />
</DeleteBadge>
)}
<PinInner $gap={4} ref={ref}>
{pinList.map((pin, index) => (
<PinBadge
key={pin.text + pinAnimationRef.current}
as="button"
onClick={playPin(pin.partStartTime, pin.interval)}
$isActive={index === activePinIndex}
$isNew={index === 0 && index === activePinIndex}
>
{pin.text}
</PinBadge>
))}
</PinInner>
</PinFlex>
</>
);
};
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;
Expand All @@ -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;
`;
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import Flex from '@/shared/components/Flex/Flex';

const VideoController = () => {
return (
<Flex $gap={8} $direction={'column'}>
<Flex $gap={12} $direction={'column'}>
<SubHeading>길이 선택</SubHeading>
<VideoIntervalStepper />

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
`;
Expand All @@ -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};
Expand Down
76 changes: 76 additions & 0 deletions frontend/src/features/killingParts/hooks/usePin.ts
Original file line number Diff line number Diff line change
@@ -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<Pin[]>([]);
const ref = useRef<HTMLDivElement>(null);

const activePinIndex = useMemo(
() =>
pinList.findIndex((pin) => pin.partStartTime === partStartTime && pin.interval === interval),
[pinList, partStartTime, interval]
);

const isPinListEmpty = pinList.length === 0;

const pinAnimationRef = useRef<number>(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;
Loading

0 comments on commit 2f445d7

Please sign in to comment.