diff --git a/src/api/recipients.js b/src/api/recipients.js index 0da173e..f77b3a8 100644 --- a/src/api/recipients.js +++ b/src/api/recipients.js @@ -52,7 +52,7 @@ export const deleteRecipient = (id) => { * @returns {Promise} - 리액션 목록 정보 */ export const getReactionsForRecipient = (id) => { - const endpoint = `/${team}/recipients/${id}/reactions/`; + const endpoint = `/recipients/${id}/reactions/`; return fetchApi(endpoint); }; diff --git a/src/components/card-list/CardList.jsx b/src/components/card-list/CardList.jsx index 22b1346..f1bba33 100644 --- a/src/components/card-list/CardList.jsx +++ b/src/components/card-list/CardList.jsx @@ -1,33 +1,65 @@ import MessageCard from '../card/MessageCard'; -import { CardlistContainer } from './CardList.styled'; +import { CardlistContainer, LoadingContainer, NoMoreContent } from './CardList.styled'; import AddCard from '../card/CardAdd'; -export default function CardList({ messages, isEditing, onDeleteMessage }) { +export default function CardList({ + messages, + isEditing, + onDeleteMessage, + onCardClick, + onAddMessage, + loading, + hasMore +}) { return ( - - - {messages.map( - ({ - id: messageId, - profileImageURL, - relationship, - sender, - content, - createdAt, - }) => ( - - ), + <> + + + {messages.map( + ({ + id: messageId, + profileImageURL, + relationship, + sender, + content, + createdAt, + ...messageData + }) => ( + onCardClick({ + id: messageId, + profileImageURL, + relationship, + sender, + content, + createdAt, + ...messageData + })} + /> + ), + )} + + + {loading && ( + +
메시지를 불러오는 중...
+
)} -
+ + {!hasMore && messages.length > 0 && ( + +
모든 메시지를 불러왔습니다.
+
+ )} + ); } diff --git a/src/components/card-list/CardList.styled.js b/src/components/card-list/CardList.styled.js index 08032b9..bd92ac3 100644 --- a/src/components/card-list/CardList.styled.js +++ b/src/components/card-list/CardList.styled.js @@ -1,9 +1,48 @@ import styled from 'styled-components'; export const CardlistContainer = styled.div` + max-width: 1200px; + margin: 100px auto; + padding: 0 24px; display: flex; - justify-content: center; + justify-content: flex-start; flex-wrap: wrap; gap: 28px 24px; - margin: 100px auto; + + @media (max-width: 1248px) { + padding: 0 24px; + + /* 카드 너비 반응형 조정 */ + & > * { + flex: 0 0 calc(50% - 12px); + max-width: calc(50% - 12px); + } + } + + @media (max-width: 768px) { + gap: 20px 16px; + + & > * { + flex: 0 0 100%; + max-width: 100%; + } + } +`; + +export const LoadingContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + padding: 40px 20px; + color: #666; + font-size: 16px; +`; + +export const NoMoreContent = styled.div` + display: flex; + justify-content: center; + align-items: center; + padding: 40px 20px; + color: #999; + font-size: 14px; `; diff --git a/src/components/card/CardAdd.jsx b/src/components/card/CardAdd.jsx index b580426..f46e1fe 100644 --- a/src/components/card/CardAdd.jsx +++ b/src/components/card/CardAdd.jsx @@ -6,28 +6,62 @@ const CardContainer = styled.div` width: 384px; height: 280px; padding: 40px; - /* background: linear-gradient(135deg, #f0f0f0 0%, #e8e8e8 100%); */ display: flex; justify-content: center; align-items: center; border-radius: 16px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); + cursor: pointer; + transition: all 0.2s ease; + background-color: #f8f9fa; + border: 2px dashed #ddd; + + &:hover { + transform: translateY(-4px); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12); + border-color: #9935ff; + background-color: #f5f0ff; + } + + @media (max-width: 1248px) { + width: 100%; + max-width: 100%; + } + + @media (max-width: 768px) { + height: auto; + min-height: 280px; + padding: 24px; + } `; const ImageBox = styled.div` width: 56px; height: 56px; + display: flex; + align-items: center; + justify-content: center; img { width: 100%; height: 100%; - /* object-fit: cover; */ + opacity: 0.7; + transition: opacity 0.2s ease; + } + + ${CardContainer}:hover & img { + opacity: 1; + } + + @media (max-width: 768px) { + width: 48px; + height: 48px; } `; -const AddCard = () => { +const AddCard = ({ onClick }) => { return ( - + plus icon diff --git a/src/components/card/MessageCard.jsx b/src/components/card/MessageCard.jsx index 8b010df..74a237c 100644 --- a/src/components/card/MessageCard.jsx +++ b/src/components/card/MessageCard.jsx @@ -26,9 +26,25 @@ const MessageCard = ({ date = '2025.07.12', isEditing, onDelete, + onClick, }) => { + const formatDate = (dateString) => { + const dateObj = new Date(dateString); + return dateObj.toLocaleDateString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).replace(/\. /g, '.').replace(/\.$/, ''); + }; + + const handleCardClick = () => { + if (!isEditing && onClick) { + onClick(); + } + }; + return ( - +
{profileImage ? ( @@ -44,7 +60,10 @@ const MessageCard = ({ {status} {isEditing && ( - )} @@ -54,7 +73,7 @@ const MessageCard = ({ {message} - {date} + {formatDate(date)} ); }; diff --git a/src/components/card/MessageCard.styled.js b/src/components/card/MessageCard.styled.js index 8533696..f77dd72 100644 --- a/src/components/card/MessageCard.styled.js +++ b/src/components/card/MessageCard.styled.js @@ -4,9 +4,28 @@ export const CardContainer = styled.div` width: 384px; height: 280px; padding: 40px; - /* background: linear-gradient(135deg, #f0f0f0 0%, #e8e8e8 100%); */ border-radius: 16px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); + cursor: ${props => props.isClickable ? 'pointer' : 'default'}; + transition: all 0.2s ease; + + ${props => props.isClickable && ` + &:hover { + transform: translateY(-4px); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12); + } + `} + + @media (max-width: 1248px) { + width: 100%; + max-width: 100%; + } + + @media (max-width: 768px) { + height: auto; + min-height: 280px; + padding: 24px; + } `; export const Header = styled.div` @@ -27,11 +46,18 @@ export const ProfileImage = styled.div` align-items: center; justify-content: center; overflow: hidden; + flex-shrink: 0; img { width: 100%; height: 100%; - /* object-fit: cover; */ + object-fit: cover; + } + + @media (max-width: 768px) { + width: 48px; + height: 48px; + margin-right: 12px; } `; @@ -72,12 +98,20 @@ export const MessageContent = styled.div` export const MessageText = styled.p` width: 100%; height: 106px; - overflow-y: auto; /* 내용이 넘치면 세로 스크롤 */ + overflow-y: auto; overflow-x: hidden; font-size: 18px; line-height: 28px; color: #4a4a4a; font-weight: 400; + margin: 0; + + @media (max-width: 768px) { + font-size: 16px; + line-height: 24px; + height: auto; + min-height: 80px; + } `; export const DateText = styled.div` diff --git a/src/components/modal/CardModal.jsx b/src/components/modal/CardModal.jsx new file mode 100644 index 0000000..d60c08c --- /dev/null +++ b/src/components/modal/CardModal.jsx @@ -0,0 +1,200 @@ +import React, { useEffect } from 'react'; +import styled from 'styled-components'; +import defaultProfile from '../../assets/svg/default_profile.svg'; + +const ModalOverlay = styled.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.7); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + padding: 20px; +`; + +const ModalContent = styled.div` + background: white; + border-radius: 16px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15); + max-width: 600px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + position: relative; +`; + +const CloseButton = styled.button` + position: absolute; + top: 20px; + right: 20px; + background: none; + border: none; + font-size: 24px; + cursor: pointer; + color: #666; + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + background-color: #f5f5f5; + } +`; + +const CardContainer = styled.div` + padding: 60px 40px 40px; +`; + +const Header = styled.div` + display: flex; + align-items: center; + margin-bottom: 24px; + padding-bottom: 20px; + border-bottom: 1px solid #ddd; +`; + +const ProfileImage = styled.div` + width: 80px; + height: 80px; + border-radius: 50%; + background: linear-gradient(135deg, #888 0%, #666 100%); + margin-right: 20px; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +`; + +const HeaderInfo = styled.div` + flex: 1; +`; + +const FromText = styled.h2` + font-size: 24px; + font-weight: 400; + margin: 0 0 8px 0; + color: #000000; +`; + +const NameText = styled.span` + font-size: 24px; + font-weight: 700; + color: #000000; +`; + +const StatusBadge = styled.div` + display: inline-block; + background: #f8f0ff; + color: #9935ff; + padding: 4px 12px; + border-radius: 6px; + font-size: 16px; + font-weight: 400; + line-height: 24px; +`; + +const MessageContent = styled.div` + min-height: 150px; + margin-bottom: 24px; +`; + +const MessageText = styled.p` + font-size: 20px; + line-height: 32px; + color: #4a4a4a; + font-weight: 400; + margin: 0; + white-space: pre-wrap; +`; + +const DateText = styled.div` + font-size: 14px; + color: #999999; + font-weight: 400; + text-align: right; +`; + +const CardModal = ({ card, onClose }) => { + if (!card) return null; + + // ESC 키로 모달 닫기 + useEffect(() => { + const handleEscKey = (e) => { + if (e.key === 'Escape') { + onClose(); + } + }; + + document.addEventListener('keydown', handleEscKey); + return () => document.removeEventListener('keydown', handleEscKey); + }, [onClose]); + + // 스크롤 방지 + useEffect(() => { + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = 'unset'; + }; + }, []); + + const handleOverlayClick = (e) => { + if (e.target === e.currentTarget) { + onClose(); + } + }; + + const formatDate = (dateString) => { + const date = new Date(dateString); + return date.toLocaleDateString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).replace(/\. /g, '.').replace(/\.$/, ''); + }; + + return ( + + + × + +
+ + {card.profileImageURL ? ( + Profile + ) : ( + Profile + )} + + + + From. {card.sender} + + {card.relationship} + +
+ + + {card.content} + + + {formatDate(card.createdAt)} +
+
+
+ ); +}; + +export default CardModal; diff --git a/src/components/subheader/ReSubheader.jsx b/src/components/subheader/ReSubheader.jsx index a4ffbaf..60228f1 100644 --- a/src/components/subheader/ReSubheader.jsx +++ b/src/components/subheader/ReSubheader.jsx @@ -1,9 +1,10 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import styled from 'styled-components'; import ProfileBadgeList from '../badge-profile/ProfileBadgeList'; import EmojiBadge from '../badge-emoji/EmojiBadge'; import Button from '../button/Button'; import Picker from '@emoji-mart/react'; +import { getReactionsForRecipient, createReaction } from '../../api/recipients'; import shareIcon from '../../assets/icon/ic_share.svg'; import emojiIcon from '../../assets/icon/ic_emoji.svg'; import arrowIcon from '../../assets/icon/ic_arrow_down.svg'; @@ -24,6 +25,17 @@ const SubHeaderInner = styled.div` justify-content: space-between; margin: 0 auto; padding: 0 24px; + + @media (max-width: 1248px) { + padding: 0 24px; + } + + @media (max-width: 768px) { + flex-direction: column; + height: auto; + padding: 16px 24px; + gap: 16px; + } `; const NameText = styled.span` @@ -35,6 +47,12 @@ const RightGroup = styled.div` display: flex; align-items: center; gap: 8px; + + @media (max-width: 768px) { + flex-wrap: wrap; + justify-content: center; + gap: 12px; + } `; const ProfileList = styled.div` @@ -135,43 +153,72 @@ const ShareButton = styled(Button)` `; export default function Subheader({ data }) { - const { name, recentMessages } = data; + const { id, name, recentMessages, topReactions = [] } = data; const [isPickerVisible, setIsPickerVisible] = useState(false); const [isDropdownOpen, setIsDropdownOpen] = useState(false); - - const [reactions, setReactions] = useState([]); - const [lastSelectedEmoji, setLastSelectedEmoji] = useState(null); - - const handleEmojiSelect = (emoji) => { - const newEmoji = emoji.native; - setLastSelectedEmoji(newEmoji); - - setReactions((prev) => { - const now = Date.now(); - const exists = prev.find((r) => r.emoji === newEmoji); - - if (exists) { - return prev.map((r) => - r.emoji === newEmoji - ? { ...r, count: r.count + 1, lastUpdated: now } - : r, - ); - } else { - return [...prev, { emoji: newEmoji, count: 1, lastUpdated: now }]; + const [reactions, setReactions] = useState(topReactions); + const [loading, setLoading] = useState(false); + + // 컴포넌트 마운트 시 반응 데이터 로드 + useEffect(() => { + loadReactions(); + }, [id]); + + const loadReactions = async () => { + try { + const reactionsData = await getReactionsForRecipient(id); + if (reactionsData.results) { + setReactions(reactionsData.results); } - }); - - setIsPickerVisible(false); + } catch (error) { + console.error('반응 로드 실패:', error); + // 실패 시 기본 데이터 사용 + setReactions(topReactions); + } }; - const sorted = [...reactions].sort((a, b) => { - if (b.count !== a.count) return b.count - a.count; - return a.lastUpdated - b.lastUpdated; - }); + const handleEmojiSelect = async (emoji) => { + const newEmoji = emoji.native; + setLoading(true); + + try { + // API로 반응 추가 + await createReaction(id, { + emoji: newEmoji, + type: 'increase' + }); + + // 로컬 상태 업데이트 + setReactions((prev) => { + const exists = prev.find((r) => r.emoji === newEmoji); + if (exists) { + return prev.map((r) => + r.emoji === newEmoji + ? { ...r, count: r.count + 1 } + : r, + ); + } else { + return [...prev, { + emoji: newEmoji, + count: 1, + id: Date.now() // 임시 ID + }]; + } + }); + + setIsPickerVisible(false); + } catch (error) { + console.error('반응 추가 실패:', error); + } finally { + setLoading(false); + } + }; - const displayReactions = sorted.slice(0, 3); - const dropdownReactions = sorted.slice(0, 8); + // 반응을 개수 많은 순으로 정렬 + const sortedReactions = [...reactions].sort((a, b) => b.count - a.count); + const displayReactions = sortedReactions.slice(0, 3); + const dropdownReactions = sortedReactions.slice(0, 8); return ( @@ -230,9 +277,10 @@ export default function Subheader({ data }) { variant="outlined" size="small" onClick={() => setIsPickerVisible((prev) => !prev)} + disabled={loading} > 이모지 아이콘 - 추가 + {loading ? '추가중...' : '추가'} {isPickerVisible && ( diff --git a/src/pages/PersonalPage.jsx b/src/pages/PersonalPage.jsx index b29d80d..5dbf424 100644 --- a/src/pages/PersonalPage.jsx +++ b/src/pages/PersonalPage.jsx @@ -1,11 +1,12 @@ -import { useState } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useParams, useLocation, useNavigate } from 'react-router-dom'; import CardList from '../components/card-list/CardList'; import Subheader from '../components/subheader/ReSubheader'; import Button from '../components/button/Button'; +import CardModal from '../components/modal/CardModal'; import useMutation from '../hooks/useMutation'; -import { deleteRecipient } from '../api/recipients'; -import { deleteMessage } from '../api/messages'; +import { deleteRecipient, getRecipient } from '../api/recipients'; +import { deleteMessage, getMessageList } from '../api/messages'; export const mockData = { id: 12321, @@ -114,19 +115,90 @@ const mockReactions = { ], }; -const initialMessages = mockData.recentMessages; - const PersonalPage = () => { const location = useLocation(); const { id: recipientId } = useParams(); const navigate = useNavigate(); - const [messages, setMessages] = useState(initialMessages); - // const { mutate, loading } = useMutation(deleteRecipient); - // const { mutate, loading } = ueMutation(deleteMessage); - + + // 상태 관리 + const [recipientData, setRecipientData] = useState(mockData); + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(false); + const [hasMore, setHasMore] = useState(true); + const [offset, setOffset] = useState(0); + const [selectedCard, setSelectedCard] = useState(null); + + const limit = 8; // 한 번에 불러올 메시지 수 + //현재 url이 '/edit'으로 끝나는지 확인 const isEditing = location.pathname.endsWith('/edit'); + // 초기 데이터 로드 + useEffect(() => { + loadInitialData(); + }, [recipientId]); + + const loadInitialData = async () => { + try { + setLoading(true); + // 받는 사람 정보 조회 + const recipient = await getRecipient(recipientId); + setRecipientData(recipient); + + // 메시지 목록 조회 (최신순) + const messageData = await getMessageList(recipientId, { + limit, + offset: 0, + sort: '-createdAt' // 최신순 정렬 + }); + + setMessages(messageData.results || []); + setHasMore(messageData.next !== null); + setOffset(limit); + } catch (error) { + console.error('초기 데이터 로드 실패:', error); + // 실패 시 mock 데이터 사용 + setMessages(mockData.recentMessages); + } finally { + setLoading(false); + } + }; + + // 무한 스크롤을 위한 추가 메시지 로드 + const loadMoreMessages = useCallback(async () => { + if (loading || !hasMore) return; + + try { + setLoading(true); + const messageData = await getMessageList(recipientId, { + limit, + offset, + sort: '-createdAt' + }); + + setMessages(prev => [...prev, ...(messageData.results || [])]); + setHasMore(messageData.next !== null); + setOffset(prev => prev + limit); + } catch (error) { + console.error('추가 메시지 로드 실패:', error); + } finally { + setLoading(false); + } + }, [recipientId, offset, loading, hasMore, limit]); + + // 스크롤 이벤트 처리 + useEffect(() => { + const handleScroll = () => { + if (window.innerHeight + document.documentElement.scrollTop + >= document.documentElement.offsetHeight - 1000) { + loadMoreMessages(); + } + }; + + window.addEventListener('scroll', handleScroll); + return () => window.removeEventListener('scroll', handleScroll); + }, [loadMoreMessages]); + const handleDeletePaper = async () => { try { await deleteRecipient(recipientId); @@ -148,15 +220,38 @@ const PersonalPage = () => { } }; + // 카드 클릭 시 확대 기능 + const handleCardClick = (message) => { + setSelectedCard(message); + }; + + const handleCloseCard = () => { + setSelectedCard(null); + }; + + // + 버튼 클릭 시 메시지 작성 페이지로 이동 + const handleAddMessage = () => { + navigate(`/post/${recipientId}/message`); + }; + return ( <> - + {isEditing &&