From 143c839dbb797837a43d55bfd06ecbb422f38bfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Thu, 3 Aug 2023 20:06:56 +0900 Subject: [PATCH] =?UTF-8?q?Feat/#188=20=EC=A1=B0=ED=9A=8C=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EB=8C=93=EA=B8=80=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?(#199)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: 댓글 조회 및 등록 msw 적용 * feat: Comment 컴포넌트 구현 * feat: CommentList 컴포넌트 구현 * feat: SongPage에 killingPart 별로 CommentList 적용 * config: ol, ul에 list-style 제거 * fix: 익명 프로필 asset 수정 * design: 킬링파트 투표 많은순으로 워딩 변경 * feat: SongPage(듣기)와 SongDetailPage(투표) 페이지 전환 추가 * fix: 킬링파트 순위 변경시 댓글이 변경되지 않던 버그 수정 --- frontend/src/assets/icon/shookshook.svg | 9 + .../src/components/CommentList/Comment.tsx | 81 +++++++++ .../components/CommentList/CommentList.tsx | 168 ++++++++++++++++++ frontend/src/components/CommentList/index.ts | 1 + .../VoteInterface/VoteInterface.tsx | 13 +- frontend/src/hooks/@common/useFetch.ts | 2 +- frontend/src/mocks/handlers/songsHandlers.ts | 29 +++ frontend/src/pages/SongDetailPage.style.ts | 2 +- frontend/src/pages/SongDetailPage.tsx | 2 + .../src/pages/SongPage/SongPage.style.tsx | 3 + frontend/src/pages/SongPage/SongPage.tsx | 73 +++++--- .../pages/SongPopularPage/SongPopularPage.tsx | 4 +- frontend/src/styles/GlobalStyles.ts | 4 + frontend/src/types/song.ts | 1 + 14 files changed, 359 insertions(+), 33 deletions(-) create mode 100644 frontend/src/assets/icon/shookshook.svg create mode 100644 frontend/src/components/CommentList/Comment.tsx create mode 100644 frontend/src/components/CommentList/CommentList.tsx create mode 100644 frontend/src/components/CommentList/index.ts diff --git a/frontend/src/assets/icon/shookshook.svg b/frontend/src/assets/icon/shookshook.svg new file mode 100644 index 000000000..afe158bf8 --- /dev/null +++ b/frontend/src/assets/icon/shookshook.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/components/CommentList/Comment.tsx b/frontend/src/components/CommentList/Comment.tsx new file mode 100644 index 000000000..ea2708a9d --- /dev/null +++ b/frontend/src/components/CommentList/Comment.tsx @@ -0,0 +1,81 @@ +import { styled } from 'styled-components'; +import shookshook from '@/assets/icon/shookshook.svg'; +import { Spacing } from '../@common'; + +interface CommentProps { + content: string; + createdAt: string; +} + +// FIXME: 분리 및 포맷 정리, ~일 전 말고도 세분화 필요 +const rtf = new Intl.RelativeTimeFormat('ko', { + numeric: 'always', +}); + +const Comment = ({ content, createdAt }: CommentProps) => { + const time = Math.ceil( + (new Date(createdAt).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24) + ); + + return ( + + + + 익명 프로필 + + + + 익명 + {rtf.format(time, 'day')} + {content} + + + + ); +}; + +export default Comment; + +const Wrapper = styled.li` + width: 100%; + margin-bottom: 16px; +`; + +const Flex = styled.div` + display: flex; + width: 100%; + align-items: flex-start; +`; + +const Profile = styled.div` + width: 40px; + height: 40px; + + border-radius: 100%; + background-color: white; + + overflow: hidden; +`; + +const Box = styled.div` + flex: 1; +`; + +const Username = styled.span` + font-size: 14px; +`; + +const RelativeTime = styled.span` + margin-left: 5px; + font-size: 12px; + color: #aaaaaa; +`; + +const Content = styled.div` + font-size: 14px; + + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + overflow: hidden; +`; diff --git a/frontend/src/components/CommentList/CommentList.tsx b/frontend/src/components/CommentList/CommentList.tsx new file mode 100644 index 000000000..fcde7bb3f --- /dev/null +++ b/frontend/src/components/CommentList/CommentList.tsx @@ -0,0 +1,168 @@ +import { useEffect, useState } from 'react'; +import { css, styled } from 'styled-components'; +import fetcher from '@/apis'; +import shookshook from '@/assets/icon/shookshook.svg'; +import useFetch from '@/hooks/@common/useFetch'; +import { useMutation } from '@/hooks/@common/useMutation'; +import { Spacing } from '../@common'; +import useToastContext from '../@common/Toast/hooks/useToastContext'; +import Comment from './Comment'; + +interface Comment { + id: number; + content: string; + createdAt: string; +} + +interface CommentListProps { + songId: string; + partId: number; +} + +const CommentList = ({ songId, partId }: CommentListProps) => { + const [newComment, setNewComment] = useState(''); + + const { data: comments, fetchData: getComment } = useFetch(() => + fetcher(`/songs/${songId}/parts/${partId}/comments`, 'GET') + ); + + const { mutateData } = useMutation(() => + fetcher(`/songs/${songId}/parts/${partId}/comments`, 'POST', { content: newComment }) + ); + const { showToast } = useToastContext(); + + const resetNewComment = () => setNewComment(''); + + const changeNewComment: React.ChangeEventHandler = ({ + currentTarget: { value }, + }) => setNewComment(value); + + const submitNewComment: React.FormEventHandler = async (event) => { + event.preventDefault(); + + await mutateData(); + + showToast('댓글이 등록되었습니다.'); + resetNewComment(); + getComment(); + }; + + useEffect(() => { + getComment(); + }, [partId]); + + if (!comments) { + return null; + } + + return ( +
+ +

댓글 {comments.length}개

+ +
+ + + 익명 프로필 + + + + + + 취소 + + + 댓글 + + +
+ + + {comments.map(({ id, content, createdAt }) => ( + + ))} + +
+ ); +}; + +export default CommentList; + +const Flex = styled.div` + display: flex; + align-items: flex-start; + gap: 14px; +`; + +const Profile = styled.div` + width: 40px; + height: 40px; + + border-radius: 100%; + background-color: white; + + overflow: hidden; +`; + +const Input = styled.input` + flex: 1; + margin: 0 8px; + border: none; + -webkit-box-shadow: none; + box-shadow: none; + margin: 0; + padding: 0; + background-color: transparent; + border-bottom: 1px solid white; + outline: none; + + font-size: 14px; +`; + +const FlexEnd = styled.div` + display: flex; + justify-content: flex-end; + gap: 10px; +`; + +const buttonBase = css` + width: 50px; + height: 36px; + + font-size: 14px; + border-radius: 10px; +`; + +const Cancel = styled.button` + ${buttonBase} + + &:hover, + &:focus { + background-color: ${({ theme }) => theme.color.secondary}; + } +`; + +const Submit = styled.button` + ${buttonBase} + + background-color: ${({ theme }) => theme.color.primary}; + + &:hover, + &:focus { + background-color: #de5484; + } + + &:disabled { + background-color: ${({ theme }) => theme.color.secondary}; + } +`; + +const Comments = styled.ol` + gap: 10px; +`; diff --git a/frontend/src/components/CommentList/index.ts b/frontend/src/components/CommentList/index.ts new file mode 100644 index 000000000..03a5519c8 --- /dev/null +++ b/frontend/src/components/CommentList/index.ts @@ -0,0 +1 @@ +export { default } from './CommentList'; diff --git a/frontend/src/components/VoteInterface/VoteInterface.tsx b/frontend/src/components/VoteInterface/VoteInterface.tsx index 6c7f19cd9..62417a2e2 100644 --- a/frontend/src/components/VoteInterface/VoteInterface.tsx +++ b/frontend/src/components/VoteInterface/VoteInterface.tsx @@ -1,8 +1,11 @@ import { useState } from 'react'; +import { Link } from 'react-router-dom'; import useVoteInterfaceContext from '@/components/VoteInterface/hooks/useVoteInterfaceContext'; import { useVideoPlayerContext } from '@/components/Youtube'; import { usePostKillingPart } from '@/hooks/killingPart'; import { ButtonContainer } from '@/pages/SongDetailPage.style'; +import { UnderLine } from '@/pages/SongPage/SongPage'; +import { PrimarySpan, SubTitle } from '@/pages/SongPage/SongPage.style'; import { getPlayingTimeText, minSecToSeconds } from '@/utils/convertTime'; import useToastContext from '../@common/Toast/hooks/useToastContext'; import { IntervalInput } from '../IntervalInput'; @@ -76,7 +79,15 @@ const VoteInterface = ({ videoLength, songId }: VoteInterfaceProps) => { return ( - 당신의 킬링파트에 투표하세요🎧 + + + 킬링파트 듣기 + + + 킬링파트 투표 + + + 당신의 킬링파트에 투표하세요 🔖 (fetcher: () => Promise) => { } finally { setIsLoading(false); } - }, []); + }, [fetcher]); useEffect(() => { fetchData(); diff --git a/frontend/src/mocks/handlers/songsHandlers.ts b/frontend/src/mocks/handlers/songsHandlers.ts index 814559a89..06127361c 100644 --- a/frontend/src/mocks/handlers/songsHandlers.ts +++ b/frontend/src/mocks/handlers/songsHandlers.ts @@ -9,6 +9,35 @@ export const songsHandlers = [ return res(ctx.json(popularSongs)); }), + rest.get(`${BASE_URL}/songs/:songId/parts/:partId/comments`, (req, res, ctx) => { + const comments = [ + { id: 1, content: '1번 댓글입니다.', createdAt: '2023-08-01T16:02:13.422Z' }, + { + id: 2, + content: '2번 댓글입니다. 200자 입니다. '.repeat(10), + createdAt: '2023-08-02T16:02:13.422Z', + }, + { id: 3, content: '3번 댓글입니다.', createdAt: '2023-08-02T16:02:13.422Z' }, + { id: 4, content: '4번 댓글입니다.', createdAt: '2023-08-02T16:02:13.422Z' }, + { id: 5, content: '5번 댓글입니다.', createdAt: '2023-08-02T16:02:13.422Z' }, + { id: 6, content: '6번 댓글입니다.', createdAt: '2023-08-02T16:02:13.422Z' }, + { id: 7, content: '7번 댓글입니다.', createdAt: '2023-08-02T16:02:13.422Z' }, + { id: 8, content: '8번 댓글입니다.', createdAt: '2023-08-02T16:02:13.422Z' }, + { id: 9, content: '9번 댓글입니다.', createdAt: '2023-08-02T16:02:13.422Z' }, + ]; + + return res(ctx.json(comments)); + }), + + rest.post(`${BASE_URL}/songs/:songId/parts/:partId/comments`, async (req, res, ctx) => { + const { songId, partId } = req.params; + const content = await req.json(); + + console.log(songId, partId, JSON.parse(content)); + + return res(ctx.status(201)); + }), + rest.get(`${BASE_URL}/songs/:songId`, (req, res, ctx) => { const { songId } = req.params; diff --git a/frontend/src/pages/SongDetailPage.style.ts b/frontend/src/pages/SongDetailPage.style.ts index 46e4b24f1..5728e1256 100644 --- a/frontend/src/pages/SongDetailPage.style.ts +++ b/frontend/src/pages/SongDetailPage.style.ts @@ -10,7 +10,7 @@ export const Container = styled.section` display: flex; width: 100%; flex-direction: column; - gap: 16px; + gap: 20px; `; export const SongInfoContainer = styled.div` diff --git a/frontend/src/pages/SongDetailPage.tsx b/frontend/src/pages/SongDetailPage.tsx index 7ee0e5f32..8ecaa4bb2 100644 --- a/frontend/src/pages/SongDetailPage.tsx +++ b/frontend/src/pages/SongDetailPage.tsx @@ -4,6 +4,7 @@ import { VoteInterface, VoteInterfaceProvider } from '@/components/VoteInterface import { VideoPlayerProvider, Youtube } from '@/components/Youtube'; import { useGetSongDetail } from '@/hooks/song'; import { Container, Singer, SongTitle, SongInfoContainer, Info } from './SongDetailPage.style'; +import { BigTitle } from './SongPage/SongPage'; const SongDetailPage = () => { const { id: songIdParam } = useParams(); @@ -17,6 +18,7 @@ const SongDetailPage = () => { return ( + 킬링파트 투표 🔖 diff --git a/frontend/src/pages/SongPage/SongPage.style.tsx b/frontend/src/pages/SongPage/SongPage.style.tsx index 223e6a5d3..edcc3cb25 100644 --- a/frontend/src/pages/SongPage/SongPage.style.tsx +++ b/frontend/src/pages/SongPage/SongPage.style.tsx @@ -34,6 +34,9 @@ export const Singer = styled.p` `; export const SubTitle = styled.h2` + display: flex; + justify-content: space-between; + align-items: center; font-size: 18px; font-weight: 700; color: ${({ theme: { color } }) => color.white}; diff --git a/frontend/src/pages/SongPage/SongPage.tsx b/frontend/src/pages/SongPage/SongPage.tsx index c06904525..1d8f4ca89 100644 --- a/frontend/src/pages/SongPage/SongPage.tsx +++ b/frontend/src/pages/SongPage/SongPage.tsx @@ -1,12 +1,14 @@ import { useEffect, useRef, useState } from 'react'; -import { useParams } from 'react-router-dom'; +import { useParams, Link } from 'react-router-dom'; +import { styled } from 'styled-components'; import { Spacing } from '@/components/@common'; import SRAlert from '@/components/@common/SRAlert'; -import SRHeading from '@/components/@common/SRHeading'; import { ToggleGroup } from '@/components/@common/ToggleGroup'; import { ToggleSwitch } from '@/components/@common/ToggleSwitch'; +import CommentList from '@/components/CommentList/CommentList'; import { KillingPartInfo } from '@/components/KillingPartInfo'; import Thumbnail from '@/components/PopularSongItem/Thumbnail'; +import { RegisterTitle } from '@/components/VoteInterface/VoteInterface.style'; import { useVideoPlayerContext } from '@/components/Youtube'; import Youtube from '@/components/Youtube/Youtube'; import { useGetSongDetail } from '@/hooks/song'; @@ -20,12 +22,10 @@ import { SubTitle, SwitchLabel, SwitchWrapper, - ToggleWrapper, } from './SongPage.style'; const SongPage = () => { - const { id } = useParams(); - + const { id = '' } = useParams(); const [isRepeat, setIsRepeat] = useState(true); const [killingRank, setKillingRank] = useState(null); const { songDetail } = useGetSongDetail(Number(id)); @@ -78,8 +78,9 @@ const SongPage = () => { return ( - 킬링파트 듣기 페이지 - + 킬링파트 듣기 🎧 + + {title} @@ -90,28 +91,33 @@ const SongPage = () => { - 킬링파트 듣기 + + 킬링파트 듣기 + + + 킬링파트 투표 + - - - - - 1st - - - - 2nd - - - - 3rd - - - - 전체 - - - + + 인기 많은 킬링파트를 들어보세요 🎧 + + + + 1st + + + + 2nd + + + + 3rd + + + + 전체 + + 반복재생 @@ -124,9 +130,20 @@ const SongPage = () => { + + {killingPart && } {`${killingRank === 4 ? '전체' : `${killingRank}등 킬링파트`} 재생`} ); }; export default SongPage; + +export const UnderLine = styled.div` + border-bottom: 2px solid white; +`; + +export const BigTitle = styled.h2` + font-size: 28px; + font-weight: 700; +`; diff --git a/frontend/src/pages/SongPopularPage/SongPopularPage.tsx b/frontend/src/pages/SongPopularPage/SongPopularPage.tsx index b64f0888b..c176dcdc5 100644 --- a/frontend/src/pages/SongPopularPage/SongPopularPage.tsx +++ b/frontend/src/pages/SongPopularPage/SongPopularPage.tsx @@ -19,14 +19,14 @@ const SongPopularPage = () => { return ( <> - 킬링파트 등록 인기순 + 킬링파트 투표 많은순 {popularSongs.map(({ id, albumCoverUrl, title, singer, totalVoteCount }, i) => (