diff --git a/src/components/common/CommentBottomSheet/GlobalCommentBottomSheet.styled.ts b/src/components/common/CommentBottomSheet/GlobalCommentBottomSheet.styled.ts new file mode 100644 index 00000000..350e6589 --- /dev/null +++ b/src/components/common/CommentBottomSheet/GlobalCommentBottomSheet.styled.ts @@ -0,0 +1,70 @@ +import styled from '@emotion/styled'; +import { colors, typography } from '@/styles/global/global'; + +export const Overlay = styled.div<{ isOpen: boolean }>` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, ${props => (props.isOpen ? '0.5' : '0')}); + z-index: 1000; + display: flex; + align-items: flex-end; + justify-content: center; + transition: background-color 0.3s ease; + opacity: ${props => (props.isOpen ? 1 : 0)}; + visibility: ${props => (props.isOpen ? 'visible' : 'hidden')}; +`; + +export const BottomSheet = styled.div<{ isOpen: boolean }>` + width: 100%; + max-width: 540px; + min-width: 360px; + background: ${colors.darkgrey.main}; + border-radius: 20px 20px 0 0; + display: flex; + flex-direction: column; + max-height: 80vh; + min-height: 50vh; + transform: translateY(${props => (props.isOpen ? '0' : '100%')}); + transition: transform 0.3s ease; + overflow: hidden; +`; + +export const Header = styled.div` + padding: 20px; + padding-bottom: 10px; + flex-shrink: 0; +`; + +export const Title = styled.h2` + color: ${colors.white}; + font-size: 20px; + font-weight: ${typography.fontWeight.bold}; + line-height: 24px; + margin: 0; +`; + +export const Content = styled.div` + flex: 1; + overflow-y: auto; + min-height: 0; +`; + +export const LoadingState = styled.div` + display: flex; + align-items: center; + justify-content: center; + height: 200px; + color: ${colors.grey[100]}; + font-size: ${typography.fontSize.sm}; + font-weight: ${typography.fontWeight.regular}; +`; + +export const InputSection = styled.div` + padding: 20px; + border-top: 1px solid ${colors.grey[400]}; + flex-shrink: 0; + background: ${colors.darkgrey.main}; +`; diff --git a/src/components/common/CommentBottomSheet/GlobalCommentBottomSheet.tsx b/src/components/common/CommentBottomSheet/GlobalCommentBottomSheet.tsx new file mode 100644 index 00000000..58b274d1 --- /dev/null +++ b/src/components/common/CommentBottomSheet/GlobalCommentBottomSheet.tsx @@ -0,0 +1,146 @@ +import { useState, useEffect, useCallback } from 'react'; +import MessageInput from '@/components/today-words/MessageInput'; +import ReplyList from '@/components/common/Post/ReplyList'; +import { getComments, type CommentData } from '@/api/comments/getComments'; +import { postReply } from '@/api/comments/postReply'; +import { useReplyActions } from '@/hooks/useReplyActions'; +import { useReplyStore } from '@/stores/useReplyStore'; +import { useCommentBottomSheetStore } from '@/stores/useCommentBottomSheetStore'; +import { + Overlay, + BottomSheet, + Header, + Title, + Content, + LoadingState, + InputSection, +} from './GlobalCommentBottomSheet.styled'; + +const GlobalCommentBottomSheet = () => { + const { isOpen, postId, postType, closeCommentBottomSheet } = useCommentBottomSheetStore(); + + const [commentList, setCommentList] = useState([]); + const [inputValue, setInputValue] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isSending, setIsSending] = useState(false); + + const { nickname, isReplying, cancelReply } = useReplyActions(); + const { parentId } = useReplyStore(); + + // 댓글 목록 로드 + const loadComments = useCallback(async () => { + if (!isOpen || !postId || !postType) return; + + setIsLoading(true); + try { + const response = await getComments(postId, { + postType, + size: 20, + }); + + if (response.data) { + setCommentList(response.data.commentList); + } + } catch (error) { + console.error('댓글 로드 실패:', error); + } finally { + setIsLoading(false); + } + }, [isOpen, postId, postType]); + + // 댓글 전송 + const handleSendComment = async () => { + if (!inputValue.trim() || isSending || !postId || !postType) return; + + setIsSending(true); + try { + const requestData = { + content: inputValue.trim(), + isReplyRequest: isReplying, + parentId: isReplying ? parentId : null, + postType: postType as 'FEED' | 'RECORD' | 'VOTE' + }; + + const response = await postReply(postId, requestData); + + if (response.isSuccess) { + setInputValue(''); + cancelReply(); // 답글 상태 초기화 + // 댓글 목록 새로고침 + await loadComments(); + } + } catch (error) { + console.error('댓글 전송 실패:', error); + } finally { + setIsSending(false); + } + }; + + // 답글 취소 + const handleCancelReply = () => { + cancelReply(); + }; + + // Overlay 클릭 처리 + const handleOverlayClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + closeCommentBottomSheet(); + } + }; + + // 바텀시트가 열릴 때 댓글 로드 + useEffect(() => { + if (isOpen) { + loadComments(); + } + }, [isOpen, postId, loadComments]); + + // 바텀시트가 닫힐 때 상태 초기화 + useEffect(() => { + if (!isOpen) { + setInputValue(''); + cancelReply(); + setCommentList([]); + } + }, [isOpen, cancelReply]); + + if (!isOpen) return null; + + return ( + + +
+ 댓글 +
+ + + {isLoading ? ( + 댓글을 불러오는 중... + ) : ( + + )} + + + + + +
+
+ ); +}; + +export default GlobalCommentBottomSheet; \ No newline at end of file diff --git a/src/components/memory/RecordItem/RecordItem.tsx b/src/components/memory/RecordItem/RecordItem.tsx index c65458d0..cea788b9 100644 --- a/src/components/memory/RecordItem/RecordItem.tsx +++ b/src/components/memory/RecordItem/RecordItem.tsx @@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom'; import type { Record } from '../../../types/memory'; import TextRecord from './TextRecord'; import PollRecord from './PollRecord'; +import { useCommentBottomSheetStore } from '@/stores/useCommentBottomSheetStore'; import heartIcon from '../../../assets/memory/heart.svg'; import heartFilledIcon from '../../../assets/memory/heart-filled.svg'; import commentIcon from '../../../assets/memory/comment.svg'; @@ -52,6 +53,9 @@ const RecordItem = ({ record, shouldBlur = false }: RecordItemProps) => { // 좋아요 상태 관리 - record 객체에서 isLiked 속성 가져오기 const [isLiked, setIsLiked] = useState(record.isLiked || false); const [currentLikeCount, setCurrentLikeCount] = useState(likeCount); + + // 전역 댓글 바텀시트 + const { openCommentBottomSheet } = useCommentBottomSheetStore(); // 길게 누르기 상태 관리 const [isPressed, setIsPressed] = useState(false); @@ -254,6 +258,11 @@ const RecordItem = ({ record, shouldBlur = false }: RecordItemProps) => { }); }, [openConfirm, handlePinRecord]); + // 댓글 버튼 클릭 핸들러 + const handleCommentClick = useCallback(() => { + openCommentBottomSheet(parseInt(id), type === 'poll' ? 'VOTE' : 'RECORD'); + }, [openCommentBottomSheet, id, type]); + // 길게 누르기 이벤트 핸들러 const handleTouchStart = useCallback( (e: React.TouchEvent) => { @@ -367,7 +376,10 @@ const RecordItem = ({ record, shouldBlur = false }: RecordItemProps) => { /> {currentLikeCount} - + { + e.stopPropagation(); + handleCommentClick(); + }}> 댓글 {commentCount} @@ -382,6 +394,7 @@ const RecordItem = ({ record, shouldBlur = false }: RecordItemProps) => { )} + ); }; diff --git a/src/pages/memory/Memory.tsx b/src/pages/memory/Memory.tsx index baf4cf7d..083ae705 100644 --- a/src/pages/memory/Memory.tsx +++ b/src/pages/memory/Memory.tsx @@ -5,6 +5,7 @@ import MemoryHeader from '../../components/memory/MemoryHeader/MemoryHeader'; import MemoryContent from '../../components/memory/MemoryContent/MemoryContent'; import MemoryAddButton from '../../components/memory/MemoryAddButton/MemoryAddButton'; import Snackbar from '../../components/common/Modal/Snackbar'; +import GlobalCommentBottomSheet from '../../components/common/CommentBottomSheet/GlobalCommentBottomSheet'; import { Container, FixedHeader, ScrollableContent, FloatingElements } from './Memory.styled'; import { getMemoryPosts } from '../../api/memory/getMemoryPosts'; import type { GetMemoryPostsParams, Post, Record } from '../../types/memory'; @@ -295,6 +296,8 @@ const Memory = () => { onClose={() => setShowSnackbar(false)} /> )} + + ); }; diff --git a/src/stores/useCommentBottomSheetStore.ts b/src/stores/useCommentBottomSheetStore.ts new file mode 100644 index 00000000..8a458e5a --- /dev/null +++ b/src/stores/useCommentBottomSheetStore.ts @@ -0,0 +1,26 @@ +import { create } from 'zustand'; + +interface CommentBottomSheetState { + isOpen: boolean; + postId: number | null; + postType: 'RECORD' | 'VOTE' | null; +} + +interface CommentBottomSheetActions { + openCommentBottomSheet: (postId: number, postType: 'RECORD' | 'VOTE') => void; + closeCommentBottomSheet: () => void; +} + +export const useCommentBottomSheetStore = create((set) => ({ + // 상태 + isOpen: false, + postId: null, + postType: null, + + // 액션 + openCommentBottomSheet: (postId: number, postType: 'RECORD' | 'VOTE') => + set({ isOpen: true, postId, postType }), + + closeCommentBottomSheet: () => + set({ isOpen: false, postId: null, postType: null }), +})); \ No newline at end of file