diff --git a/.storybook/preview.ts b/.storybook/preview.ts deleted file mode 100644 index d5888fe..0000000 --- a/.storybook/preview.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { Preview } from '@storybook/react-vite' - -const preview: Preview = { - parameters: { - controls: { - matchers: { - color: /(background|color)$/i, - date: /Date$/i, - }, - }, - - a11y: { - // 'todo' - show a11y violations in the test UI only - // 'error' - fail CI on a11y violations - // 'off' - skip a11y checks entirely - test: 'todo', - }, - }, -} - -export default preview diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx new file mode 100644 index 0000000..29cf011 --- /dev/null +++ b/.storybook/preview.tsx @@ -0,0 +1,31 @@ +// .storybook/preview.ts +import type { Preview } from '@storybook/react-vite' +import React from 'react' +import { ThemeProvider } from 'styled-components' +import { theme } from '@/styles/theme' +import { GlobalStyle } from '@/styles/globalStyle' + +const preview: Preview = { + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + a11y: { + test: 'todo', + }, + }, + + decorators: [ + (Story) => ( + + + + + ), + ], +} + +export default preview diff --git a/package-lock.json b/package-lock.json index ce1fab9..eefb5dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@iconify/react": "^6.0.0", + "@mui/icons-material": "^7.2.0", "@mui/material": "^7.2.0", "apexcharts": "^5.2.0", "axios": "^1.10.0", @@ -15602,6 +15603,32 @@ "url": "https://opencollective.com/mui-org" } }, + "node_modules/@mui/icons-material": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.2.0.tgz", + "integrity": "sha512-gRCspp3pfjHQyTmSOmYw7kUQTd9Udpdan4R8EnZvqPeoAtHnPzkvjBrBqzKaoAbbBp5bGF7BcD18zZJh4nwu0A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^7.2.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@mui/material": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.2.0.tgz", diff --git a/package.json b/package.json index 18039ba..ce2d8a4 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@iconify/react": "^6.0.0", + "@mui/icons-material": "^7.2.0", "@mui/material": "^7.2.0", "apexcharts": "^5.2.0", "axios": "^1.10.0", diff --git a/src/components/feature/movieDetail/DebateCard.tsx b/src/components/feature/movieDetail/DebateCard.tsx index 6c4b59b..2784b90 100644 --- a/src/components/feature/movieDetail/DebateCard.tsx +++ b/src/components/feature/movieDetail/DebateCard.tsx @@ -58,18 +58,32 @@ const UserAvatar = styled.div<{ $backgroundImage?: string | null }>` background-position: center; ` +// 텍스트 아바타를 BaseHeaderVer2와 동일하게 수정 +const TextAvatar = styled.div` + width: 30px; + height: 30px; + border-radius: 50%; + background-color: #999; + color: white; + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; + font-size: 0.9rem; +` + const UserInfo = styled.div` display: flex; flex-direction: column; ` const Username = styled.div` - font-size: 12px; + font-size: 14px; font-weight: 500; ` const CreatedAt = styled.div` - font-size: 8px; + font-size: 12px; color: #aaa; ` @@ -217,8 +231,8 @@ const MainImage = styled.img` const SpoilerBadge = styled.div` background: linear-gradient(135deg, #ff6b6b, #ff8e53); color: white; - font-size: 10px; - padding: 4px 8px; + font-size: 12px; + padding: 8px 8px; border-radius: 12px; font-weight: bold; margin-left: auto; @@ -300,7 +314,12 @@ const DebateCard: React.FC = ({
- + {/* BaseHeaderVer2와 동일한 로직으로 수정 */} + {profileImage && profileImage !== 'https://placehold.co/600x600' ? ( + + ) : ( + {username?.charAt(0) || '유'} + )} {username} {createdAt} diff --git a/src/components/starRating/DefaultRating.tsx b/src/components/starRating/DefaultRating.tsx new file mode 100644 index 0000000..89f0b1a --- /dev/null +++ b/src/components/starRating/DefaultRating.tsx @@ -0,0 +1,56 @@ +// src/components/starRating/DefaultRating.tsx +import React from 'react' +import Rating from '@mui/material/Rating' +import { styled } from 'styled-components' +import StarIcon from '@mui/icons-material/Star' + +export interface DefaultRatingProps { + value: number | null | undefined // 현재 선택된 평점 값 + precision?: number // 소수점 표시 단위 (기본값: 0.1) + onChange?: (event: React.SyntheticEvent, value: number | null) => void + roundTo?: number // 시각적으로 표시할 반올림 단위 (기본값: 0.5) + min?: number // 최소 선택 가능한 값 (기본값: 1) + readOnly?: boolean // 사용자 수정 가능 여부 + size?: 'small' | 'medium' | 'large' +} + +const StarFull = styled(StarIcon)` + fill: gold; + stroke: white; + stroke-width: 0.1; +` + +const StarEmpty = styled(StarIcon)` + stroke: white; + stroke-width: 0.1; + fill: transparent; +` + +export const DefaultRating: React.FC = ({ + value, + onChange, + precision = 0.1, + roundTo = 0.1, + min = 1, + readOnly = false, + size = 'medium', + }) => { + const roundedValue = value === null ? null : Math.round((value||1) / roundTo) * roundTo + + return ( + } + emptyIcon={} + onChange={(event, newValue) => { + if (!readOnly) { + const finalValue = newValue !== null && newValue < min ? min : newValue + onChange?.(event, finalValue) + } + }} + /> + ) +} diff --git a/src/components/starRating/RatingCard.tsx b/src/components/starRating/RatingCard.tsx index 3a3a425..60a19e9 100644 --- a/src/components/starRating/RatingCard.tsx +++ b/src/components/starRating/RatingCard.tsx @@ -1,15 +1,15 @@ // src/components/common/RatingCard.tsx import styled from 'styled-components' -import StarRating from '@/components/starRating/StarRating' -import BaseContainer from '../common/BaseContainer' -import React from 'react' +import BaseContainer from '@/components/common/BaseContainer' +import React, { useState } from 'react' +import { DefaultRating } from '@/components/starRating/DefaultRating' type RatingCardProps = { title: string - rating: number - size?: number + rating: null | undefined | number + size?: number // MUI 기준: small | medium | large editable?: boolean - onRate?: (value: number) => void + onRate?: (value: number | null | undefined) => void } const CardContainer = styled(BaseContainer)` @@ -28,11 +28,42 @@ const Title = styled.p` color: white; ` -const RatingCard: React.FC = ({ title, rating, size = 24 }) => { +const RatingCard: React.FC = ({ + title, + rating, + size = 24, + editable = false, + onRate, +}) => { + const [localRating, setLocalRating] = useState(rating) + + // MUI Rating size 대응 + const getMUISize = (): 'small' | 'medium' | 'large' => { + if (size <= 20) return 'small' + if (size <= 30) return 'medium' + return 'large' + } + return ( - - {title}{title.includes('평점') && `: ${rating.toFixed(1)} / 5.0`} + { + if (newValue !== null) { + setLocalRating(newValue) + onRate?.(newValue) + } + }} + /> + + {title} + {!editable && `: ${localRating?.toFixed(1)} / 5.0`} + {editable && `: ${localRating?.toFixed(1)} / 5.0`} + ) } diff --git a/src/components/starRating/StarRating.tsx b/src/components/starRating/StarRating.tsx index ea59f1f..d0d396f 100644 --- a/src/components/starRating/StarRating.tsx +++ b/src/components/starRating/StarRating.tsx @@ -1,74 +1,36 @@ -// src/component/starRating/StartRating.tsx -import styled from 'styled-components' +// src/components/starRating/StarRating.tsx import React from 'react' +import styled from 'styled-components' +import { DefaultRating } from '@/components/starRating/DefaultRating' type StarRatingProps = { - rating: number; // 0.0 ~ 5.0 - size?: number; -}; + rating: number // 0.0 ~ 5.0 + size?: number // icon font size + roundTo?: number // 반올림 단위 (기본값: 0.5) + precision?: number // 소수점 표시 단위 (기본값: 0.1) +} -const StarWrapper = styled.div` +const Wrapper = styled.div` display: flex; - gap: 4px; -`; - -const getSvgSize = (size?: number) => `${size ?? 20}px` - -const StyledSvg = styled.svg<{ $size?: number }>` - width: ${({ $size }) => getSvgSize($size)}; - height: ${({ $size }) => getSvgSize($size)}; - stroke: gold; - stroke-width: 0.8; - stroke-linejoin: round; - stroke-linecap: round; - shape-rendering: geometricPrecision; -`; - -// const FilledStar = styled(StyledSvg)` -// fill: gold; -// ` -// -// const EmptyStyledSvg = styled(StyledSvg)` -// fill: none; -// ` - -const StarRating: React.FC = ({ rating, size = 20 }) => { - const starPath = - 'M12 2L15.09 8.26L22 9.27L17 14.14L18.18 21L12 17.77L5.82 21L7 14.14L2 9.27L8.91 8.26L12 2Z' - const transform = 'scale(4.1667)'; // 100 / 24 = 4.1667 - - const renderStar = (index: number) => { - const filled = Math.max(0, Math.min(1, rating - index)) // clamp to [0,1] - const fillPercentage = filled * 100 - - return ( - - - - - - - - - - - - {/* outline */} - - - {/* fill with mask */} - - - ); - }; - - return {[0, 1, 2, 3, 4].map(renderStar)} - } - - export default StarRating + align-items: center; +` + +const StarRating: React.FC = ({ + rating, + roundTo = 0.5, + precision = 0.1, +}) => { + return ( + + + + ) +} + +export default StarRating diff --git a/src/components/starRating/StarRatingSingle.tsx b/src/components/starRating/StarRatingSingle.tsx deleted file mode 100644 index 33dda8a..0000000 --- a/src/components/starRating/StarRatingSingle.tsx +++ /dev/null @@ -1,47 +0,0 @@ -// src/components/starRating/StarRatingSingle.tsx - -import styled from 'styled-components' -import React from 'react' - -type StarRatingSingleProps = { - fillRatio: number // 0 ~ 100 - size?: number -} - -const getSvgSize = (size?: number) => `${size ?? 20}px` - -const StyledSvg = styled.svg<{ $size?: number }>` - width: ${({ $size }) => getSvgSize($size)}; - height: ${({ $size }) => getSvgSize($size)}; -` - -const StarRatingSingle: React.FC = ({ fillRatio, size = 20 }) => { - const clampedRatio = Math.max(0, Math.min(100, fillRatio)) - const fillWidth = (clampedRatio / 100) * 1000 - - const filledStarPath = ` - "M91.5193 45.3632L73.9412 60.5312L79.2966 83.2148C79.5921 84.4462 79.516 85.7375 79.078 86.9257C78.6399 88.1138 77.8595 89.1455 76.8354 89.8904C75.8113 90.6353 74.5895 91.0599 73.3241 91.1107C72.0588 91.1615 70.8068 90.8361 69.7263 90.1757L49.9998 78.0351L30.2615 90.1757C29.1811 90.8323 27.9306 91.1546 26.6675 91.1019C25.4043 91.0493 24.185 90.6241 23.163 89.8798C22.1411 89.1356 21.3622 88.1056 20.9244 86.9196C20.4866 85.7336 20.4095 84.4445 20.7029 83.2148L26.0779 60.5312L8.49976 45.3632C7.54389 44.5371 6.85259 43.4477 6.51219 42.231C6.17179 41.0143 6.19737 39.7243 6.58575 38.5221C6.97413 37.3199 7.70809 36.2587 8.69596 35.4711C9.68384 34.6836 10.8818 34.2045 12.1404 34.0937L35.1873 32.2343L44.0779 10.7187C44.5591 9.54611 45.3782 8.5431 46.4309 7.8372C47.4836 7.1313 48.7225 6.75439 49.99 6.75439C51.2575 6.75439 52.4963 7.1313 53.5491 7.8372C54.6018 8.5431 55.4209 9.54611 55.9021 10.7187L64.7888 32.2343L87.8357 34.0937C89.0967 34.2003 90.2982 34.6768 91.2897 35.4632C92.2812 36.2497 93.0185 37.3113 93.4093 38.515C93.8001 39.7187 93.827 41.0109 93.4866 42.2298C93.1462 43.4487 92.4537 44.5401 91.4958 45.3671L91.5193 45.3632Z" - ` - - const blankStarPath = ` - M91.5193 45.3632L73.9412 60.5312L79.2966 83.2148C79.5921 84.4462 79.516 85.7375 79.078 86.9257C78.6399 88.1138 77.8595 89.1455 76.8354 89.8904C75.8113 90.6353 74.5895 91.0599 73.3241 91.1107C72.0588 91.1615 70.8068 90.8361 69.7263 90.1757L49.9998 78.0351L30.2615 90.1757C29.1811 90.8323 27.9306 91.1546 26.6675 91.1019C25.4043 91.0493 24.185 90.6241 23.163 89.8798C22.1411 89.1356 21.3622 88.1056 20.9244 86.9196C20.4866 85.7336 20.4095 84.4445 20.7029 83.2148L26.0779 60.5312L8.49976 45.3632C7.54389 44.5371 6.85259 43.4477 6.51219 42.231C6.17179 41.0143 6.19737 39.7243 6.58575 38.5221C6.97413 37.3199 7.70809 36.2587 8.69596 35.4711C9.68384 34.6836 10.8818 34.2045 12.1404 34.0937L35.1873 32.2343L44.0779 10.7187C44.5591 9.54611 45.3782 8.5431 46.4309 7.8372C47.4836 7.1313 48.7225 6.75439 49.99 6.75439C51.2575 6.75439 52.4963 7.1313 53.5491 7.8372C54.6018 8.5431 55.4209 9.54611 55.9021 10.7187L64.7888 32.2343L87.8357 34.0937C89.0967 34.2003 90.2982 34.6768 91.2897 35.4632C92.2812 36.2497 93.0185 37.3113 93.4093 38.515C93.8001 39.7187 93.827 41.0109 93.4866 42.2298C93.1462 43.4487 92.4537 44.5401 91.4958 45.3671L91.5193 45.3632Z - ` - - return ( - - - - - - - - {/* 빈 별 테두리 */} - - - {/* 채워진 영역 (clip) */} - - - ) -} - -export default StarRatingSingle diff --git a/src/pages/debate/DebateDetailPage.tsx b/src/pages/debate/DebateDetailPage.tsx index d3eddff..e87aecd 100644 --- a/src/pages/debate/DebateDetailPage.tsx +++ b/src/pages/debate/DebateDetailPage.tsx @@ -28,6 +28,7 @@ import { createComment, getComments, deleteComment, + getUserDebateReaction } from '@/services/debate' import { useAuth } from '@/context/AuthContext' @@ -402,7 +403,7 @@ const DebateDetailPage: React.FC = () => { return () => window.removeEventListener('resize', checkMobile) }, []) - // 토론 상세 데이터 가져오기 (기존 코드 유지) + // 토론 상세 데이터 가져오기 (기존 코드 수정) useEffect(() => { const fetchDebateDetail = async () => { try { @@ -429,7 +430,35 @@ const DebateDetailPage: React.FC = () => { } fetchDebateDetail() - }, [id]) + }, [id]) // isAuthenticated 제거 + + // 사용자 반응 상태 조회를 별도 useEffect로 분리 + useEffect(() => { + const fetchUserReaction = async () => { + try { + if (!id || !debate) return + + if (isAuthenticated) { + const reactionResponse = await getUserDebateReaction(parseInt(id)) + if (reactionResponse.success) { + setIsLiked(reactionResponse.data.isLiked || false) + setIsDisliked(reactionResponse.data.isHated || false) + } + } else { + // 비로그인 사용자는 기본값 + setIsLiked(false) + setIsDisliked(false) + } + } catch (error) { + console.error('사용자 반응 상태 조회 실패:', error) + // 에러가 발생해도 기본값으로 설정 + setIsLiked(false) + setIsDisliked(false) + } + } + + fetchUserReaction() + }, [id, debate, isAuthenticated]) // debate 상태가 설정된 후에 실행 // 댓글 목록 가져오기 (페이지네이션) const fetchComments = useCallback( @@ -945,11 +974,11 @@ const DebateDetailPage: React.FC = () => { {debate.debateTitle} - {/* 프로필 이미지 또는 텍스트 아바타 표시 */} - {debate.profileImage ? ( + {/* BaseHeaderVer2와 동일한 로직으로 수정 */} + {debate.profileImage && debate.profileImage !== 'https://placehold.co/600x600' ? ( ) : ( - {debate.nickname?.charAt(0) || '익'} + {debate.nickname?.charAt(0) || '유'} )} {debate.nickname} @@ -1114,8 +1143,9 @@ const DebateDetailPage: React.FC = () => { + {/* 댓글 작성자도 동일한 로직 적용 */} - {comment.memberNickname?.charAt(0) || '익'} + {comment.memberNickname?.charAt(0) || '유'} {comment.memberNickname} @@ -1293,7 +1323,7 @@ const RatingBadge = styled.span` gap: 0.3rem; ` -// 프로필 이미지 관련 styled-components 추가 +// 프로필 이미지 관련 styled-components 수정 (BaseHeaderVer2와 동일하게) const ProfileAvatar = styled.img` width: 24px; height: 24px; @@ -1306,14 +1336,13 @@ const TextAvatar = styled.div` width: 24px; height: 24px; border-radius: 50%; - background-color: #5025d1; + background-color: #999; color: white; display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 0.8rem; - border: 1px solid #444; ` const CommentProfileAvatar = styled.img` @@ -1328,14 +1357,13 @@ const CommentTextAvatar = styled.div` width: 20px; height: 20px; border-radius: 50%; - background-color: #5025d1; + background-color: #999; color: white; display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 0.7rem; - border: 1px solid #444; ` // 페이지네이션 관련 styled-components 추가 diff --git a/src/pages/debate/DebateEditPage.tsx b/src/pages/debate/DebateEditPage.tsx index 61cfdd7..8472026 100644 --- a/src/pages/debate/DebateEditPage.tsx +++ b/src/pages/debate/DebateEditPage.tsx @@ -337,7 +337,7 @@ const DebateEditPage: React.FC = () => { background: '#1e1e2f', color: '#fff', }).then(() => { - navigate(`/debate/${debate.debateId}`) + navigate(`/debate/${debate.debateId}`, { replace: true }) }) } } catch (error) { diff --git a/src/pages/debate/DebateWritePage.tsx b/src/pages/debate/DebateWritePage.tsx index 76c45ba..ab230f7 100644 --- a/src/pages/debate/DebateWritePage.tsx +++ b/src/pages/debate/DebateWritePage.tsx @@ -259,7 +259,7 @@ const DebateWritePage: React.FC = () => { spoiler, }) .then(res => { - navigate(`/debate/${res.data.debateId}`) + navigate(`/debate/${res.data.debateId}`, { replace: true }) }) .catch(() => { Swal.fire({ diff --git a/src/pages/movie/MovieDetailPage.tsx b/src/pages/movie/MovieDetailPage.tsx index e2e6c0a..07912f5 100644 --- a/src/pages/movie/MovieDetailPage.tsx +++ b/src/pages/movie/MovieDetailPage.tsx @@ -6,7 +6,7 @@ import ReviewDebateCard from '@/components/feature/movieDetail/ReviewDebateCard' import React, { useCallback, useEffect, useState, useRef } from 'react' import RatingCard from '@/components/starRating/RatingCard' import MovieDetailHeader from '@/pages/movie/MovieDetailHeader' -import { Eye, EyeOff, Flag, ListPlus, Star, StarOff } from 'lucide-react' +import { Eye, EyeOff, Flag, ListPlus, Star, StarOff, ChevronDown } from 'lucide-react' import { mapToMyReviewData, mapToReviewData, Review, ReviewData } from '@/pages/movie/reviewData' import { MovieData } from '@/pages/movie/movieData' @@ -36,7 +36,6 @@ import watchaImg from '@/assets/platform/watcha.png' import disneyPlusImg from '@/assets/platform/disney_plus.png' import wavveImg from '@/assets/platform/wavve.png' import PlaylistAddModal from '@/components/feature/PlaylistAddModal' -import { ChevronDown } from 'lucide-react' const MovieDetailLayout = styled.div` display: flex; @@ -278,16 +277,16 @@ const ContentsListTitleTab = styled.div` align-items: center; ` -const ContentsListOrderDropdown = styled.div` - width: 80px; - height: 30px; - display: flex; - border-radius: 5px; - background-color: #191513; - text-align: center; - align-items: center; - justify-content: center; -` +// const ContentsListOrderDropdown = styled.div` +// width: 80px; +// height: 30px; +// display: flex; +// border-radius: 5px; +// background-color: #191513; +// text-align: center; +// align-items: center; +// justify-content: center; +// ` const TabButton = styled.button<{ $active: boolean }>` all: unset; @@ -499,7 +498,8 @@ export default function MovieDetailPage() { const onClickAuth = useOnClickAuth() const [reviewData, setReviewData] = useState(null) - const [myReview, setMyReview] = useState(null) + const [myReview, setMyReview] = useState(null) + const [myRating, setMyRating] = useState(1) // 내 별점 상태 추가 // const [debateData, setDebateData] = useState(null) // 토론 관련 state 추가 @@ -643,77 +643,63 @@ export default function MovieDetailPage() { if (activeTab === 'review') { fetchReviewsBySort() } - }, [sortBy, currentPage, activeTab]) + }, [sortBy, currentPage, activeTab, myReview]) - useEffect(() => { - const fetchMovieDetail = async () => { - try { - // setIsLoading(true) - console.log('영화 상세 정보 불러오기 시작, 영화 ID : ', tmdbId, typeof tmdbId) - const response = await getMovieDetail(tmdbId) - const data = response.data - console.log('영화 정보 조회됨 : ', data) - const mappedData: MovieData = mapToMovieData(data) - console.log('영화 정보 매핑됨 : ', mappedData) - console.log('영화 providers 확인 : ', mappedData.providers) - setMovieData(mappedData) - // setIsLiked(mappedData.myLike) - setIsWatched(mappedData.myWatched) - setIsBookmarked(mappedData.myBookmark) - } catch (error) { - console.error('영화 상세 정보 불러오기 실패:', error) - } finally { - console.log('영화 상세 정보 불러오기 및 매핑 완료') - } + const fetchMovieDetail = async () => { + try { + // setIsLoading(true) + console.log('영화 상세 정보 불러오기 시작, 영화 ID : ', tmdbId, typeof tmdbId) + const response = await getMovieDetail(tmdbId) + const data = response.data + console.log('영화 정보 조회됨 : ', data) + const mappedData: MovieData = mapToMovieData(data) + console.log('영화 정보 매핑됨 : ', mappedData) + console.log('영화 providers 확인 : ', mappedData.providers) + setMovieData(mappedData) + // setIsLiked(mappedData.myLike) + setIsWatched(mappedData.myWatched) + setIsBookmarked(mappedData.myBookmark) + } catch (error) { + console.error('영화 상세 정보 불러오기 실패:', error) + } finally { + console.log('영화 상세 정보 불러오기 및 매핑 완료') } - const fetchMovieReview = async () => { - try { - console.log('영화 리뷰 불러오기 시작, 영화 ID : ', tmdbId, typeof tmdbId) - const response = await getMovieReview(tmdbId, 0) - const data = response.data - console.log('영화 리뷰 조회됨 : ', data) - console.log('영화 리뷰 매핑시 사용된 유저 정보 : ', user, isAuthenticated, user?.id) - const mappedData: ReviewData = mapToReviewData(data, user?.id, user?.nickname) - console.log('영화 리뷰 매핑됨 : ', mappedData) - // setMyReview(mappedData) - setReviewData(mappedData) - } catch (error) { - console.error('영화 리뷰 불러오기 실패:', error) - } finally { - console.log('영화 리뷰 불러오기 및 매핑 완료') - } + } + const fetchMovieReview = async () => { + try { + console.log('영화 리뷰 불러오기 시작, 영화 ID : ', tmdbId, typeof tmdbId) + const response = await getMovieReview(tmdbId, 0) + const data = response.data + console.log('영화 리뷰 조회됨 : ', data) + console.log('영화 리뷰 매핑시 사용된 유저 정보 : ', user, isAuthenticated, user?.id) + const mappedData: ReviewData = mapToReviewData(data, user?.id, user?.nickname) + console.log('영화 리뷰 매핑됨 : ', mappedData) + // setMyReview(mappedData) + setReviewData(mappedData) + } catch (error) { + console.error('영화 리뷰 불러오기 실패:', error) + } finally { + console.log('영화 리뷰 불러오기 및 매핑 완료') } + } - const fetchMyReview = async () => { - try { - console.log('내 리뷰 불러오기 시작, 영화 ID : ', tmdbId, typeof tmdbId) - const response = await getMyMovieReview(tmdbId) - const data = response.data - console.log('내 리뷰 조회됨 : ', data) - const mappedData: Review | null = mapToMyReviewData(data) - console.log('내 리뷰 매핑됨 : ', mappedData) - setMyReview(mappedData) - } catch (error) { - console.error('내 리뷰 불러오기 실패:', error) - } finally { - console.log('내 리뷰 불러오기 및 매핑 완료') - } + const fetchMyReview = async () => { + try { + console.log('내 리뷰 불러오기 시작, 영화 ID : ', tmdbId, typeof tmdbId) + const response = await getMyMovieReview(tmdbId) + const data = response.data + console.log('내 리뷰 조회됨 : ', data) + const mappedData: Review | null = mapToMyReviewData(data) + console.log('내 리뷰 매핑됨 : ', mappedData) + setMyReview(mappedData) + setMyRating(mappedData ? mappedData.rating : 1) // 내 별점 설정 + } catch (error) { + console.error('내 리뷰 불러오기 실패:', error) + } finally { + console.log('내 리뷰 불러오기 및 매핑 완료') } - // const fetchMovieDebate = async () => { - // try { - // console.log("토론장 불러오기 시작, 영화 ID : ", tmdbId, typeof tmdbId) - // const response = await getMovieDebate(tmdbId, 0) - // const data = response.data - // console.log("토론장 조회됨 : ", response.data) - // const mappedData = mapToDebateData(data, user?.id, user?.nickname) - // console.log("토론장 매핑됨 : ", mappedData) - // setDebateData(mappedData) - // } catch (error) { - // console.error('토론장 불러오기 실패:', error) - // } finally { - // console.log("토론장 불러오기 완료") - // } - // } + } + useEffect(() => { try { if (loading) return // 로딩 중이면 아무것도 하지 않음 @@ -744,7 +730,7 @@ export default function MovieDetailPage() { } finally { setIsLoading(false) } - }, [tmdbId, user, loading, isAuthenticated, activeTab]) // activeTab 의존성 추가 + }, [tmdbId, user, loading, isAuthenticated, activeTab, isLoading]) // activeTab 의존성 추가 // HTML에서 이미지 URL 추출하는 함수 const extractImagesFromContent = (htmlContent: string): string[] => { @@ -930,32 +916,42 @@ export default function MovieDetailPage() { {activeTab === 'review' && ( - - + + + { + setMyRating(value) + }} + /> + +

리뷰를 저장해야 별점이 적용됩니다!

{ try { - const myReviewResponse = await getMyMovieReview(tmdbId!) - const data = myReviewResponse.data - const mappedData: Review | null = mapToMyReviewData(data) - setMyReview(mappedData) - const reviewResponse = await getMovieReview(tmdbId!, 0) - const reviewData = reviewResponse.data - const mappedReviewData: ReviewData = mapToReviewData( - reviewData, - user?.id, - user?.nickname, - ) - setReviewData(mappedReviewData) + console.log('리뷰 저장 후 갱신 시작') + await new Promise(resolve => setTimeout(resolve, 500)) // 응답 대기는 유지 + console.log('대기 시간 후 갱신 시작') + await fetchMovieDetail() // 전체 평점 다시 받아오기 + await fetchMyReview() // 내 평점/리뷰도 다시 받아오기 } catch (error) { - console.error('내 리뷰 불러오기 실패:', error) + console.error('리뷰 저장 후 갱신 실패:', error) } }} /> @@ -964,7 +960,6 @@ export default function MovieDetailPage() { 리뷰 ({reviewData?.totalElements}) - {/* TODO : 정렬 버튼 및 랜더링 구현하기*/} setIsDropdownOpen(!isDropdownOpen)}> {getSortLabel(sortBy)} diff --git a/src/pages/movie/ReviewTextArea.tsx b/src/pages/movie/ReviewTextArea.tsx index 0427f1c..5e7a652 100644 --- a/src/pages/movie/ReviewTextArea.tsx +++ b/src/pages/movie/ReviewTextArea.tsx @@ -52,7 +52,7 @@ const TextareaButton = styled(BaseButton)` interface ReviewTextAreaProps { tmdbId: string onSuccess?: () => void - rating: number // 평점 추가 + rating: number | null | undefined // 평점 추가 isAuthenticated: boolean // 인증 여부 추가 myReview?: Review | null // 수정 모드일 때 기존 리뷰 데이터 } @@ -133,7 +133,7 @@ export const ReviewTextArea = ({ await updateMovieReview(myReview.contentId, { content: value, spoiler: isSpoiler, - star: 5, + star: rating || 1, }) await Swal.fire({ icon: 'success', @@ -149,7 +149,7 @@ export const ReviewTextArea = ({ }) await createMovieReview({ tmdbId: parseInt(tmdbId), - star: 5, + star: rating || 1, content: value, spoiler: isSpoiler, }) diff --git a/src/pages/movie/movieData.ts b/src/pages/movie/movieData.ts index d422f93..b3d139a 100644 --- a/src/pages/movie/movieData.ts +++ b/src/pages/movie/movieData.ts @@ -56,3 +56,5 @@ export interface MovieData { images: string[]; videos: string[]; } + + diff --git a/src/pages/movie/reviewData.ts b/src/pages/movie/reviewData.ts index 3a978e3..c78fb92 100644 --- a/src/pages/movie/reviewData.ts +++ b/src/pages/movie/reviewData.ts @@ -39,7 +39,7 @@ export function mapToMyReviewData(data: any): Review | null { }, tmdbId: review.tmdbId, movieTitle: review.movieTitle, - contentId: review.contentId, + contentId: review.reviewId, createdAt: review.createdAt, updatedAt: review.updatedAt, content: review.content, diff --git a/src/services/debate.ts b/src/services/debate.ts index 09a079c..1dbe762 100644 --- a/src/services/debate.ts +++ b/src/services/debate.ts @@ -134,3 +134,8 @@ export const toggleDebateLike = async (debateId: number, isLike: boolean) => { const res = await axiosInstance.post(`/api/v1/debate/${debateId}/${endpoint}`) return res.data } + +export const getUserDebateReaction = async (debateId: number) => { + const res = await axiosInstance.get(`/api/v1/debate/${debateId}/user-reaction`) + return res.data +} diff --git a/src/stories/components/feature/movieDetail/ReviewDebateCard.stories.tsx b/src/stories/components/feature/movieDetail/ReviewDebateCard.stories.tsx deleted file mode 100644 index 561d09c..0000000 --- a/src/stories/components/feature/movieDetail/ReviewDebateCard.stories.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import React from 'react' -import type { Meta, StoryObj } from '@storybook/react' -import ReviewDebateCard from '@/components/feature/movieDetail/ReviewDebateCard' - -import { ThemeProvider } from 'styled-components' -import { GlobalStyle } from '@/styles/globalStyle' -import { theme } from '@/styles/theme' - - -const meta: Meta = { - title: 'Common/ReviewDebateCard', - component: ReviewDebateCard, - tags: ['autodocs'], // 필요 시 자동 문서화 - parameters: { - layout: 'centered', - }, - decorators: [ - (Story) => ( - - -
- -
-
- ), - ], -} - -export default meta -type Story = StoryObj - -export const Default: Story = { - args: { - username: '홍길동', - createdAt: '2025-07-18', - content: '이 영화 정말 감동적이었어요! 다시 보고 싶을 정도!', - rating: 4.5, - likes: 12, - comments: 3, - isMyPost: false, - }, -} - -export const MyPost: Story = { - args: { - username: '나 자신', - createdAt: '2025-07-17', - content: '내가 쓴 리뷰입니다. 수정도 하고 싶어요.', - rating: 3.0, - likes: 7, - comments: 0, - isMyPost: true, - }, -} - -export const WithoutRatingAndComments: Story = { - args: { - username: '익명', - createdAt: '2025-07-16', - content: '별점이나 댓글이 없는 리뷰입니다.', - likes: 2, - }, -} diff --git a/src/stories/components/starRating/DefaultRating.stories.tsx b/src/stories/components/starRating/DefaultRating.stories.tsx new file mode 100644 index 0000000..a37bb2f --- /dev/null +++ b/src/stories/components/starRating/DefaultRating.stories.tsx @@ -0,0 +1,103 @@ +import React, { useState } from 'react' +import { Meta, StoryObj } from '@storybook/react' +import { DefaultRating, DefaultRatingProps } from '@/components/starRating/DefaultRating' + +const meta: Meta = { + title: 'Components/StarRating/DefaultRating', + component: DefaultRating, + tags: ['autodocs'], + argTypes: { + value: { + control: { type: 'number', min: 0, max: 5, step: 0.1 }, + }, + precision: { + control: { type: 'number', min: 0.1, max: 1, step: 0.1 }, + }, + roundTo: { + control: { type: 'number', min: 0.1, max: 1, step: 0.1 }, + }, + size: { + control: 'radio', + options: ['small', 'medium', 'large'], + }, + readOnly: { + control: 'boolean', + }, + }, +} + +export default meta +type Story = StoryObj + +/** + * 사용자가 직접 조작 가능한 인터랙티브 스토리 + */ +export const Interactive: Story = { + args: { + value: 3.5, + precision: 0.5, + roundTo: 0.5, + min: 1.0, + size: 'large', + readOnly: false, + }, + render: (args: DefaultRatingProps) => { + function Wrapper() { + const [rating, setRating] = useState(args.value) + + return ( +
+ setRating(newValue)} /> +
현재 별점: {rating}
+
+ ) + } + + return + }, +} + +/** + * 읽기 전용 별점 + */ +export const ReadOnly: Story = { + args: { + value: 4.3, + readOnly: true, + size: 'large', + precision: 0.1, + roundTo: 0.1, + }, + render: (args: DefaultRatingProps) => { + function Wrapper() { + const [rating, setRating] = useState(args.value) + + return ( +
+ setRating(newValue)} /> +
현재 별점: {rating}
+
+ ) + } + return + }, +} + +/** + * 커스텀 스타일 테스트용 + */ +export const CustomStyle: Story = { + args: { + value: 1.7, + size: 'medium', + precision: 0.1, + readOnly: false, + roundTo: 0.5, + }, + render: (args: DefaultRatingProps) => ( +
+ +
별점: {args.value}
+
+ ), +} diff --git a/src/stories/components/starRating/RatingCard.stories.tsx b/src/stories/components/starRating/RatingCard.stories.tsx new file mode 100644 index 0000000..3ff859e --- /dev/null +++ b/src/stories/components/starRating/RatingCard.stories.tsx @@ -0,0 +1,63 @@ +// src/components/starRating/RatingCard.stories.tsx +import React, { useState } from 'react' +import type { Meta, StoryObj } from '@storybook/react' +import RatingCard from '@/components/starRating/RatingCard' + +const meta: Meta = { + title: 'Components/StarRating/RatingCard', + component: RatingCard, + tags: ['autodocs'], + argTypes: { + rating: { + control: { type: 'number', min: 0, max: 5, step: 0.1 }, + }, + editable: { + control: 'boolean', + }, + size: { + control: { type: 'number', min: 16, max: 48, step: 4 }, + }, + }, +} +export default meta + +type Story = StoryObj + +export const StaticRating: Story = { + render: args => { + function Wrapper() { + const [value, setValue] = useState(args.rating ?? 3.0) + + return setValue(val)} /> + } + + return + }, + args: { + title: '전체 평점', + rating: 4.2, + editable: false, + }, +} + +export const EditableRating: Story = { + render: args => { + function Wrapper() { + const [value, setValue] = useState(args.rating ?? 3.0) + + return ( + setValue(val)} + /> + ) + } + return + }, + args: { + rating: 3.0, + }, +} diff --git a/src/stories/components/starRating/StarRatingSingle.stories.tsx b/src/stories/components/starRating/StarRatingSingle.stories.tsx deleted file mode 100644 index 95aab2c..0000000 --- a/src/stories/components/starRating/StarRatingSingle.stories.tsx +++ /dev/null @@ -1,29 +0,0 @@ -// src/stories/components/starRating/StarRatingSingle.stories.tsx - -import { Meta, StoryObj } from '@storybook/react'; -import StarRatingSingle from '@/components/starRating/StarRatingSingle'; - -const meta: Meta = { - title: 'Components/StarRating/StarRatingSingle', - component: StarRatingSingle, - tags: ['autodocs'], - argTypes: { - fillRatio: { - control: { type: 'range', min: 0, max: 100, step: 10 }, - }, - size: { - control: { type: 'number' }, - }, - }, -}; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = { - args: { - fillRatio: 50, - size: 40, - }, -}; diff --git a/src/styles/storybookGlobalStyle.ts b/src/styles/storybookGlobalStyle.ts index 270fbd3..bb283c0 100644 --- a/src/styles/storybookGlobalStyle.ts +++ b/src/styles/storybookGlobalStyle.ts @@ -2,16 +2,53 @@ import { createGlobalStyle } from 'styled-components' export const SBGlobalStyle = createGlobalStyle` + /* 1) box-sizing, margin/padding 초기화 */ *, *::before, *::after { box-sizing: border-box; + margin: 0; + padding: 0; + } + + /* 2) html/body 전역 스타일 (앱 GlobalStyle 참고) */ + html, body { + font-family: ${({ theme }) => theme.fontFamily}; + font-weight: ${({ theme }) => theme.fontWeight}; + color: ${({ theme }) => theme.colors.text}; + background-color: ${({ theme }) => theme.colors.background}; + background-image: ${({ theme }) => theme.backgroundImage}; + line-height: 1.5; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + overflow-y: scroll; + } + + /* 3) 스크롤바 스타일 (WebKit) */ + ::-webkit-scrollbar { + width: 8px; + height: 8px; + } + ::-webkit-scrollbar-thumb { + background-color: rgba(255, 255, 255, 0.3); + border-radius: 4px; + } + ::-webkit-scrollbar-track { + background-color: transparent; + } + + /* 4) 스크롤바 스타일 (Firefox) */ + * { + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.3) transparent; } - body { + /* 5) 링크, 문단 초기화 */ + a { + text-decoration: none; + color: inherit; + } + p { margin: 0; padding: 0; - font-family: var(--font-base); - background-color: transparent; /* background는 preview.ts에서 설정 */ - color: inherit; } - `