diff --git a/package-lock.json b/package-lock.json index 1cb617d..f26ea30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "emoji-picker-react": "^4.15.0", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-infinite-scroll-component": "^6.1.0", "react-router": "^7.9.5", "styled-components": "^6.1.19" }, @@ -2864,6 +2865,18 @@ "react": "^19.2.0" } }, + "node_modules/react-infinite-scroll-component": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/react-infinite-scroll-component/-/react-infinite-scroll-component-6.1.0.tgz", + "integrity": "sha512-SQu5nCqy8DxQWpnUVLx7V7b7LcA37aM7tvoWjTLZp1dk6EJibM5/4EJKzOnl07/BsM1Y40sKLuqjCwwH/xV0TQ==", + "license": "MIT", + "dependencies": { + "throttle-debounce": "^2.1.0" + }, + "peerDependencies": { + "react": ">=16.0.0" + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -3096,6 +3109,15 @@ "node": ">=8" } }, + "node_modules/throttle-debounce": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-2.3.0.tgz", + "integrity": "sha512-H7oLPV0P7+jgvrk+6mwwwBDmxTaxnu9HMXmloNLXwnNO0ZxZ31Orah2n8lU1eMPvsaowP2CX+USCgyovXfdOFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", diff --git a/package.json b/package.json index 4af7841..a88f007 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "emoji-picker-react": "^4.15.0", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-infinite-scroll-component": "^6.1.0", "react-router": "^7.9.5", "styled-components": "^6.1.19" }, diff --git a/src/App.jsx b/src/App.jsx index 928e238..440141b 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -18,8 +18,12 @@ function App() { }> } /> } /> - } /> - } /> + {/* } /> */} + + {/* 롤링 페이퍼 뷰어/편집 모드 */} + } /> + } /> + } /> } /> } /> diff --git a/src/api/client.js b/src/api/client.js new file mode 100644 index 0000000..4dcc951 --- /dev/null +++ b/src/api/client.js @@ -0,0 +1,22 @@ +import axios from "axios"; + +// 기수-팀 번호 설정 (환경변수로 관리 가능) +const TEAM_CODE = "2-1"; // 추후 환경변수로 변경 가능 + +// API 기본 설정 +const BASE_URL = `https://rolling-api.vercel.app/${TEAM_CODE}`; + +/** + * API 클라이언트 + * 책임: axios 인스턴스 생성 및 기본 설정 + */ +const apiClient = axios.create({ + baseURL: BASE_URL, + timeout: 10000, + headers: { + "Content-Type": "application/json", + }, +}); + +export default apiClient; +export { TEAM_CODE }; diff --git a/src/api/rolling-page-api.js b/src/api/rolling-page-api.js new file mode 100644 index 0000000..e23567b --- /dev/null +++ b/src/api/rolling-page-api.js @@ -0,0 +1,86 @@ +import apiClient from "./client"; + +/** + * Recipients API 함수들 + * 책임: Recipients 관련 API 호출 + */ + +// 유저 상세 조회 + +export const getRecipientById = async (recipientId) => { + try { + const response = await apiClient.get(`/recipients/${recipientId}/`); + return response.data; + } catch (error) { + console.error(`Failed to fetch recipient ${recipientId}:`, error); + throw error; + } +}; + +// 롤링 페이퍼 전체 삭제 + +export const deleteRecipient = async (recipientId) => { + try { + const response = await apiClient.delete(`/recipients/${recipientId}/`); + return response.data; + } catch (error) { + console.error(`Failed to delete recipient ${recipientId}:`, error); + throw error; + } +}; + + +// 유저의 모든 리액션 조회 + +export const getReactions = async (recipientId, params = {}) => { + try { + const response = await apiClient.get(`/recipients/${recipientId}/reactions/`, { + params, + }); + return response.data; + } catch (error) { + console.error(`Failed to fetch reactions for recipient ${recipientId}:`, error); + throw error; + } +}; + + +// 수신자에게 리액션 추가/감소 + +export const addReaction = async (recipientId, data) => { + try { + const response = await apiClient.post(`/recipients/${recipientId}/reactions/`, data); + return response.data; + } catch (error) { + console.error(`Failed to add reaction to recipient ${recipientId}:`, error); + throw error; + } +}; + + +// 수신자의 메시지 목록 조회 + +export const getRecipientMessages = async (recipientId, { limit = 6, offset = 0 } = {}) => { + try { + const response = await apiClient.get(`/recipients/${recipientId}/messages/`, { + params: { limit, offset }, + }); + return response.data; + } catch (error) { + console.error(`Failed to fetch messages for recipient ${recipientId}:`, error); + throw error; + } +}; + +// 메시지 삭제 + +export const deleteMessage = async (messageId) => { + try { + const response = await apiClient.delete(`/messages/${messageId}/`); + return response.data; + } catch (error) { + console.error(`Failed to delete message ${messageId}:`, error); + throw error; + } +}; + diff --git a/src/components/common/global-layout.jsx b/src/components/common/global-layout.jsx index 143890f..dc1d37d 100644 --- a/src/components/common/global-layout.jsx +++ b/src/components/common/global-layout.jsx @@ -5,9 +5,7 @@ const PAGES_WITH_BUTTON = ["main", "list"]; export default function GlobalLayout() { const location = useLocation(); - const showButton = PAGES_WITH_BUTTON.some((page) => - location.pathname.includes(page) - ); + const showButton = PAGES_WITH_BUTTON.some((page) => location.pathname.includes(page)); return ( <> diff --git a/src/components/common/header.jsx b/src/components/common/header.jsx index ccb1485..a4ef1c8 100644 --- a/src/components/common/header.jsx +++ b/src/components/common/header.jsx @@ -1,7 +1,8 @@ -import { Link } from "react-router"; +import { Link, useLocation } from "react-router"; import styled from "styled-components"; import logo from "@/assets/icons/logo.svg"; import Button from "@/components/common/button"; +import media from "@/styles/media"; const ContainWrapper = styled.div` position: sticky; @@ -9,6 +10,10 @@ const ContainWrapper = styled.div` background-color: white; border-bottom: 1px solid #ededed; z-index: 1003; + + ${props => props.$isRollingPage && media.small` + display: none; + `} `; const Contain = styled.div` @@ -35,9 +40,12 @@ const ButtonWrapper = styled.div` `; export default function Header({ showButton }) { + const location = useLocation(); + const isRollingPage = location.pathname.startsWith('/post/'); + return ( <> - + diff --git a/src/components/common/modal-layout.jsx b/src/components/common/modal-layout.jsx index af54b7a..e6b032d 100644 --- a/src/components/common/modal-layout.jsx +++ b/src/components/common/modal-layout.jsx @@ -1,82 +1,84 @@ -import React from 'react'; -import styled from 'styled-components'; -import { colors } from '@/styles/colors'; -import { font } from '@/styles/font'; -import media from '@/styles/media'; - -const Overlay = styled.div` - position: fixed; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - background: transparent; - z-index: 999; -`; - -const ModalContainer = styled.div` - background: white; - border-radius: 16px; - padding: 40px; - width: 480px; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); - - ${media.medium` - width: 400px; - padding: 30px; - `} - - ${media.small` - width: 320px; - padding: 24px; - `} -`; - -const ModalTitle = styled.h2` - ${font.bold24} - color: ${colors.gray[900]}; - margin-bottom: 24px; - text-align: center; -`; - -const ModalContent = styled.div` - width: 100%; -`; - -const CloseButton = styled.button` - width: 100%; - margin-top: 16px; - padding: 6px; - background: transparent; - border: 1px solid ${colors.gray[300]}; - border-radius: 8px; - cursor: pointer; - ${font.regular16} - color: ${colors.gray[700]}; - transition: all 0.2s; - - &:hover { - background: ${colors.gray[50]}; - } -`; - -/** - * 공통 모달 레이아웃 컴포넌트 - * 책임: 모달의 기본 구조와 레이아웃 제공 - */ -export default function ModalLayout({ isOpen, onClose, title, children, showCloseButton = true }) { - if (!isOpen) return null; - - return ( - - e.stopPropagation()}> - {title && {title}} - {children} - {showCloseButton && ( - 닫기 - )} - - - ); -} - +import React from "react"; +import styled from "styled-components"; +import { colors } from "@/styles/colors"; +import { font } from "@/styles/font"; +import media from "@/styles/media"; + +const Overlay = styled.div` + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 999; +`; + +const ModalContainer = styled.div` + background: white; + border-radius: 16px; + padding: 40px; + width: 600px; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + + ${media.medium` + width: 600px; + padding: 30px; + `} + + ${media.small` + width: 320px; + padding: 24px; + `} +`; + +const ModalTitle = styled.h2` + ${font.bold24} + color: ${colors.gray[900]}; + margin-bottom: 24px; + text-align: center; +`; + +const ModalContent = styled.div` + width: 100%; +`; + +const CloseButton = styled.button` + width: 100%; + margin-top: 16px; + padding: 6px; + background: transparent; + border: 1px solid ${colors.gray[300]}; + border-radius: 8px; + cursor: pointer; + ${font.regular16} + color: ${colors.gray[700]}; + transition: all 0.2s; + + &:hover { + background: ${colors.gray[50]}; + } +`; + +/** + * 공통 모달 레이아웃 컴포넌트 + * 책임: 모달의 기본 구조와 레이아웃 제공 + */ +export default function ModalLayout({ isOpen, onClose, title, children, showCloseButton = true }) { + if (!isOpen) return null; + + return ( + + e.stopPropagation()}> + {title && {title}} + {children} + {showCloseButton && 닫기} + + + ); +} diff --git a/src/components/common/toast-provider.jsx b/src/components/common/toast-provider.jsx index a54e466..97f7154 100644 --- a/src/components/common/toast-provider.jsx +++ b/src/components/common/toast-provider.jsx @@ -76,7 +76,7 @@ export function ToastProvider({ children }) { )} , - toastContainer + toastContainer, )} ); diff --git a/src/components/common/toast.jsx b/src/components/common/toast.jsx index 023441f..7ab8c90 100644 --- a/src/components/common/toast.jsx +++ b/src/components/common/toast.jsx @@ -37,8 +37,7 @@ const ToastStyle = styled.div` color: white; padding: 19px 30px; border-radius: 8px; - animation: ${({ $isClosing }) => ($isClosing ? fadeOut : fadeIn)} 0.3s - ease-in-out forwards; + animation: ${({ $isClosing }) => ($isClosing ? fadeOut : fadeIn)} 0.3s ease-in-out forwards; ${font.regular16} button { @@ -56,8 +55,10 @@ const ToastStyle = styled.div` ${media.medium` width: 524px; + position: absolute; left: calc(50% - 262px); - bottom: 50px; + bottom: 10%; + transform: translate(-50%, 50%); `} ${media.small` diff --git a/src/components/rolling/card-contents.jsx b/src/components/rolling/card-contents.jsx index 4c1336b..c6c3361 100644 --- a/src/components/rolling/card-contents.jsx +++ b/src/components/rolling/card-contents.jsx @@ -1,59 +1,187 @@ -import { - CardContainer, - Card, - CardEditButton, - CardContentContainer, - CardContentStatus, - CardContentStatusContainer, - CardContentStatusProfileImage, - CardContentStatusProfileName, - CardContentStatusRelationship, - CardContentText, - CardContentDate, - CardContentStatusProfileContainer, - CardContentDeleteButton, -} from "@/styles/rolling-page-styles"; -import { useState } from "react"; -import useCards from "@/hooks/use-cards"; - - -export default function CardContents({ maxVisible = 6 }) { - const [cards] = useState([ - { id: 1, name: '김철수', profileImageURL: 'https://via.placeholder.com/28', relationship: 'friend' }, - { id: 2, name: '이영희', profileImageURL: 'https://via.placeholder.com/28', relationship: 'family' }, - { id: 3, name: '박민수', profileImageURL: 'https://via.placeholder.com/28', relationship: 'colleague' }, - { id: 4, name: '최영희', profileImageURL: 'https://via.placeholder.com/28', relationship: 'acquaintance' }, - { id: 5, name: 'dsadsa', profileImageURL: 'https://via.placeholder.com/28', relationship: 'friend' }, - { id: 6, name: 'qweqwe', profileImageURL: 'https://via.placeholder.com/28', relationship: 'family' }, - { id: 7, name: 'zxczxc', profileImageURL: 'https://via.placeholder.com/28', relationship: 'colleague' }, - { id: 8, name: 'm,nmnb', profileImageURL: 'https://via.placeholder.com/28', relationship: 'acquaintance' } - ]); - const { visibleCards } = useCards(cards, maxVisible); - - return ( - <> - - - {visibleCards.map((card) => ( - - - - - - - From. {card.name} - {card.relationship} - - - - - sdasdsa - 2025.11.12 - - - - ))} - - - ); -} \ No newline at end of file +import React, { useEffect, useState } from "react"; +import { useNavigate } from "react-router"; +import InfiniteScroll from "react-infinite-scroll-component"; +import { + CardContainer, + Card, + CardEditButton, + CardContentContainer, + CardContentStatus, + CardContentStatusContainer, + CardContentStatusProfileImage, + CardContentStatusProfileName, + CardContentStatusRelationship, + CardContentText, + CardContentDate, + CardContentStatusProfileContainer, + CardContentDeleteButton, +} from "@/styles/rolling-page-styles"; +import { useInfiniteRecipientMessages } from "@/hooks/use-infinite-recipients"; +import { useDeleteActions } from "@/hooks/use-delete-actions"; +import CardDetailModal from "./card-detail-modal"; +import DeleteConfirmModal from "./delete-confirm-modal"; + +/** + * 카드 컨텐츠 컴포넌트 (무한 스크롤) + * 책임: 메시지 카드 목록 표시 및 무한 스크롤 처리 + * @param {number} recipientId - 수신자 ID + * @param {boolean} isEditMode - 편집 모드 여부 (true: 편집 가능, false: 뷰어) + */ +export default function CardContents({ recipientId, isEditMode = false }) { + const navigate = useNavigate(); + const { messages, hasMore, fetchInitialData, fetchMoreData, refresh } = + useInfiniteRecipientMessages(recipientId, isEditMode); + + // 삭제 액션 훅 + const { handleDeleteMessage } = useDeleteActions(); + + // 모달 상태 관리 + const [selectedMessage, setSelectedMessage] = useState(null); + const [isDetailModalOpen, setIsDetailModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [messageToDelete, setMessageToDelete] = useState(null); + + // 초기 데이터 로드 + useEffect(() => { + fetchInitialData(); + }, [fetchInitialData]); + + // 카드 클릭 핸들러 + const handleCardClick = (message) => { + setSelectedMessage(message); + setIsDetailModalOpen(true); + }; + + const handleCardEditClick = () => { + navigate(`/post/${recipientId}/message`); + }; + + // 상세 모달 닫기 + const handleCloseDetailModal = () => { + setIsDetailModalOpen(false); + setSelectedMessage(null); + }; + + // 삭제 확인 모달 열기 + const handleOpenDeleteModal = (message) => { + setMessageToDelete(message); + setIsDeleteModalOpen(true); + }; + + // 삭제 확인 모달 닫기 + const handleCloseDeleteModal = () => { + setIsDeleteModalOpen(false); + setMessageToDelete(null); + }; + + // 메시지 삭제 실행 + const handleConfirmDelete = async () => { + if (!messageToDelete) return; + + const success = await handleDeleteMessage(messageToDelete.id, () => { + refresh(); // 목록 갱신 + }); + + if (success) { + handleCloseDeleteModal(); + } + }; + + const formatDate = (dateString) => { + const date = new Date(dateString); + return date.toLocaleDateString("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }).replace(/\. /g, ".").replace(/\.$/, ""); // 마지막 점만 제거 + }; + + + // 관계 라벨 매핑 + const relationshipMap = { + 친구: "friend", + 가족: "family", + 동료: "colleague", + 지인: "acquaintance", + }; + + + + return ( + <> + + 모든 메시지를 확인했습니다 +

+ } + > + + {/* 뷰어 모드일 때만 카드 추가 버튼 표시 */} + {!isEditMode && ( + + handleCardEditClick(recipientId)} /> + + )} + + {messages.map((message) => ( + handleCardClick(message)}> + + + + + + + From. {message.sender} + + + {message.relationship} + + + + + {/* 편집 모드일 때만 카드 삭제 버튼 표시 */} + {isEditMode && ( + { + e.stopPropagation(); // 카드 클릭 이벤트 방지 + handleOpenDeleteModal(message); + }} + /> + )} + + {message.content} + {formatDate(message.createdAt)} + + + ))} + +
+ + {/* 카드 상세 모달 */} + < CardDetailModal + isOpen={isDetailModalOpen} + onClose={handleCloseDetailModal} + message={selectedMessage} + /> + + {/* 삭제 확인 모달 */} + < DeleteConfirmModal + isOpen={isDeleteModalOpen} + onClose={handleCloseDeleteModal} + onConfirm={handleConfirmDelete} + title="메시지 삭제" + message={`${messageToDelete?.sender}님의 메시지를 삭제하시겠습니까?` + } + /> + + ); +} diff --git a/src/components/rolling/card-detail-modal.jsx b/src/components/rolling/card-detail-modal.jsx new file mode 100644 index 0000000..735d98a --- /dev/null +++ b/src/components/rolling/card-detail-modal.jsx @@ -0,0 +1,137 @@ +import React from "react"; +import styled from "styled-components"; +import ModalLayout from "@/components/common/modal-layout"; +import Button from "@/components/common/button"; +import { colors } from "@/styles/colors"; +import { font } from "@/styles/font"; + +const ProfileSection = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + padding-bottom: 20px; + border-bottom: 1px solid ${colors.gray[200]}; +`; + +const ProfileContainer = styled.div` + display: flex; + align-items: center; + gap: 16px; + +`; +const ProfileImage = styled.img` + width: 56px; + height: 56px; + border-radius: 100px; + border: 1px solid ${colors.gray[300]}; +`; + +const ProfileInfo = styled.div` + display: flex; + flex-direction: column; + gap: 6px; +`; + +const ProfileName = styled.div` + ${font.regular16} + color: ${colors.gray[900]}; +`; + +const RelationshipBadge = styled.div` + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 8px; + height: 20px; + border-radius: 4px; + ${font.regular14} + width: fit-content; +`; + +const MessageContent = styled.div` + ${font.regular18} + color:${colors.gray[600]}; + line-height: 24px; + white-space: pre-wrap; + word-break: break-word; + overflow-y: auto; + margin-bottom: 24px; + padding-top: 16px; +`; + +const MessageDate = styled.div` + ${font.regular14} + color: ${colors.gray[400]}; +`; + +const ButtonWrapper = styled.div` + display: flex; + justify-content: center; +`; + +// 관계별 배경색 및 텍스트 색상 +const relationshipColors = { + 친구: { bg: colors.blue[100], text: colors.blue[500] }, + 가족: { bg: colors.green[100], text: colors.green[500] }, + 동료: { bg: colors.purple[100], text: colors.purple[600] }, + 지인: { bg: colors.beige[100], text: colors.beige[500] }, +}; + +/** + * 카드 상세 모달 컴포넌트 + * 책임: 메시지 전체 내용을 모달로 표시 + */ +export default function CardDetailModal({ isOpen, onClose, message }) { + if (!message) return null; + + const relationshipStyle = relationshipColors[message.relationship] || { + bg: colors.gray[100], + text: colors.gray[500], + }; + + // 날짜 포맷팅 + const formatDate = (dateString) => { + const date = new Date(dateString); + return date.toLocaleDateString("ko-KR", { + year: "numeric", + month: "long", + day: "numeric", + }); + }; + + return ( + + + + + + + From. {message.sender} + + + {message.relationship} + + + + {formatDate(message.createdAt)} + + + + {message.content} + + + + + + + ); +} + diff --git a/src/components/rolling/delete-confirm-modal.jsx b/src/components/rolling/delete-confirm-modal.jsx new file mode 100644 index 0000000..f0b2887 --- /dev/null +++ b/src/components/rolling/delete-confirm-modal.jsx @@ -0,0 +1,52 @@ +import React from "react"; +import styled from "styled-components"; +import ModalLayout from "@/components/common/modal-layout"; +import Button from "@/components/common/button"; +import { colors } from "@/styles/colors"; +import { font } from "@/styles/font"; + +const ModalContent = styled.div` + text-align: center; +`; + +const ModalMessage = styled.p` + ${font.regular18} + color: ${colors.gray[700]}; + margin-bottom: 32px; + line-height: 1.6; + white-space: pre-wrap; +`; + +const ButtonGroup = styled.div` + display: flex; + gap: 12px; + justify-content: center; +`; + +/** + * 삭제 확인 모달 컴포넌트 + * 책임: 삭제 전 사용자 확인 받기 + */ +export default function DeleteConfirmModal({ isOpen, onClose, onConfirm, title, message }) { + const handleConfirm = () => { + onConfirm(); + onClose(); + }; + + return ( + + + {message} + + + + + + + ); +} + diff --git a/src/components/rolling/emoji-display-list.jsx b/src/components/rolling/emoji-display-list.jsx index 3e1492f..2aa45fe 100644 --- a/src/components/rolling/emoji-display-list.jsx +++ b/src/components/rolling/emoji-display-list.jsx @@ -1,24 +1,23 @@ -import React from 'react'; -import { - RollingHeaderImojiIconContainer, - RollingHeaderImojiText, - RollingHeaderImojiIcon, -} from '@/styles/rolling-page-styles'; - -/** - * 이모지 표시 리스트 컴포넌트 - * 책임: 상위 N개의 이모지를 화면에 표시 - */ -export default function EmojiDisplayList({ emojis }) { - return ( - <> - {emojis.map((emojiData, index) => ( - - {emojiData.emoji} - {emojiData.count} - - ))} - - ); -} - +import React from "react"; +import { + RollingHeaderEmojiIconContainer, + RollingHeaderEmojiText, + RollingHeaderEmojiIcon, +} from "@/styles/rolling-page-styles"; + +/** + * 이모지 표시 리스트 컴포넌트 + * 책임: 상위 N개의 이모지를 화면에 표시 + */ +export default function EmojiDisplayList({ emojis }) { + return ( + <> + {emojis.map((emojiData, index) => ( + + {emojiData.emoji} + {emojiData.count} + + ))} + + ); +} diff --git a/src/components/rolling/emoji-dropdown.jsx b/src/components/rolling/emoji-dropdown.jsx index b5bcc45..6b73e43 100644 --- a/src/components/rolling/emoji-dropdown.jsx +++ b/src/components/rolling/emoji-dropdown.jsx @@ -1,108 +1,100 @@ -import React from 'react'; -import styled from 'styled-components'; -import { colors } from '@/styles/colors'; -import media from '@/styles/media'; -import { font } from '@/styles/font'; -import { RollingHeaderArrowDown } from '@/styles/rolling-page-styles'; - -const EmojiDropdownContainer = styled.div` - position: relative; - display: inline-block; -`; - -const EmojiDropdownWrapper = styled.div` - position: fixed; - transform: translate(-80%, 10%); - z-index: 1000; - background: white; - border-radius: 8px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - border: 1px solid ${colors.gray[300]}; - padding: 24px; - width: auto; - max-height: 300px; - overflow-y: auto; -`; - -const EmojiDropdownGrid = styled.div` - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 8px; - ${media.medium` - grid-template-columns: repeat(3, 1fr); - `} - ${media.small` - grid-template-columns: repeat(3, 1fr); - `} -`; - -const EmojiDropdownItem = styled.div` - display: flex; - align-items: center; - justify-content: center; - width: auto; - height: auto; - padding: 8px 12px; - text-align: center; - border-radius: 32px; - background: rgba(153, 153, 153, 1); - gap: 2px; - - ${media.small` - padding: 4px 8px; - `} -`; - -const EmojiDropdownIcon = styled.div``; - -const EmojiDropdownCount = styled.span` - ${font.regular16} - color: rgba(255, 255, 255, 1); -`; - -const Overlay = styled.div` - position: fixed; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - background: transparent; - z-index: 999; -`; - -/** - * 이모지 드롭다운 컴포넌트 - * 책임: 모든 이모지 목록을 드롭다운 형태로 표시 - */ -export default function EmojiDropdown({ - emojis, - isOpen, - onToggle, - onClose, - arrowDownIcon -}) { - return ( - - - {isOpen && ( - <> - - - - {emojis.map((emojiData, index) => ( - - {emojiData.emoji} - {emojiData.count} - - ))} - - - - )} - - ); -} - +import React from "react"; +import styled from "styled-components"; +import { colors } from "@/styles/colors"; +import media from "@/styles/media"; +import { font } from "@/styles/font"; +import { RollingHeaderArrowDown } from "@/styles/rolling-page-styles"; + +const EmojiDropdownContainer = styled.div` + position: relative; + display: inline-block; +`; + +const EmojiDropdownWrapper = styled.div` + position: fixed; + transform: translate(-80%, 10%); + z-index: 1000; + background: white; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + border: 1px solid ${colors.gray[300]}; + padding: 24px; + width: auto; + max-height: 300px; + overflow-y: auto; +`; + +const EmojiDropdownGrid = styled.div` + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; + ${media.medium` + grid-template-columns: repeat(3, 1fr); + `} + ${media.small` + grid-template-columns: repeat(3, 1fr); + `} +`; + +const EmojiDropdownItem = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: auto; + height: auto; + padding: 8px 12px; + text-align: center; + border-radius: 32px; + background: rgba(153, 153, 153, 1); + gap: 2px; + + ${media.small` + padding: 4px 8px; + `} +`; + +const EmojiDropdownIcon = styled.div``; + +const EmojiDropdownCount = styled.span` + ${font.regular16} + color: rgba(255, 255, 255, 1); +`; + +const Overlay = styled.div` + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: transparent; + z-index: 999; +`; + +/** + * 이모지 드롭다운 컴포넌트 + * 책임: 이모지 목록을 드롭다운 형태로 표시 (API에서 이미 정렬된 상위 8개) + */ +export default function EmojiDropdown({ emojis, isOpen, onToggle, onClose, arrowDownIcon }) { + // API에서 이미 카운트 순으로 정렬되어 최대 8개만 제공됨 + const topEmojis = emojis; + return ( + + + {isOpen && ( + <> + + + + {topEmojis.map((emojiData, index) => ( + + {emojiData.emoji} + {emojiData.count} + + ))} + + + + )} + + ); +} diff --git a/src/components/rolling/emoji-picker-component.jsx b/src/components/rolling/emoji-picker-component.jsx index fc13037..ad9d4b3 100644 --- a/src/components/rolling/emoji-picker-component.jsx +++ b/src/components/rolling/emoji-picker-component.jsx @@ -1,62 +1,61 @@ -import React from 'react'; -import EmojiPicker from 'emoji-picker-react'; -import styled from 'styled-components'; -import { colors } from '@/styles/colors'; - -const EmojiPickerContainer = styled.div` - position: relative; - display: inline-block; -`; - -const EmojiPickerWrapper = styled.div` - position: fixed; - transform: translate(-60%, 2%); - z-index: 1000; - background: white; - border-radius: 8px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - border: 1px solid ${colors.gray[300]}; -`; - -const Overlay = styled.div` - position: fixed; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - background: transparent; - z-index: 999; -`; - -/** - * 이모지 선택기 컴포넌트 - * 책임: 이모지 피커 UI 렌더링 및 이모지 선택 이벤트 처리 - */ -export default function EmojiPickerComponent({ isOpen, onClose, onEmojiSelect, children }) { - const handleEmojiClick = (emojiData) => { - onEmojiSelect(emojiData.emoji); - onClose(); - }; - - return ( - - {children} - {isOpen && ( - <> - - - - - - )} - - ); -} - +import React from "react"; +import EmojiPicker from "emoji-picker-react"; +import styled from "styled-components"; +import { colors } from "@/styles/colors"; + +const EmojiPickerContainer = styled.div` + position: relative; + display: inline-block; +`; + +const EmojiPickerWrapper = styled.div` + position: fixed; + transform: translate(-60%, 2%); + z-index: 1000; + background: white; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + border: 1px solid ${colors.gray[300]}; +`; + +const Overlay = styled.div` + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: transparent; + z-index: 999; +`; + +/** + * 이모지 선택기 컴포넌트 + * 책임: 이모지 피커 UI 렌더링 및 이모지 선택 이벤트 처리 + */ +export default function EmojiPickerComponent({ isOpen, onClose, onEmojiSelect, children }) { + const handleEmojiClick = (emojiData) => { + onEmojiSelect(emojiData.emoji); + onClose(); + }; + + return ( + + {children} + {isOpen && ( + <> + + + + + + )} + + ); +} diff --git a/src/components/rolling/header-action-buttons.jsx b/src/components/rolling/header-action-buttons.jsx index b00531d..49b5695 100644 --- a/src/components/rolling/header-action-buttons.jsx +++ b/src/components/rolling/header-action-buttons.jsx @@ -1,55 +1,54 @@ -import React from 'react'; -import styled from 'styled-components'; -import EmojiPickerComponent from './emoji-picker-component'; -import { - RollingHeaderImojiEditButtonContainer, - RollingHeaderImojiEditButton, - RollingHeaderImojiEditButtonIcon, - RollingHeaderImojiEditButtonText, - PerpendicularLineSecond, - RollingHeaderLinkShareButton, -} from '@/styles/rolling-page-styles'; - -const ShareButtonWrapper = styled.div` - position: relative; -`; - -/** - * 헤더 액션 버튼 컴포넌트 - * 책임: 이모지 추가 버튼과 공유 버튼 렌더링 - */ -export default function HeaderActionButtons({ - isEmojiPickerOpen, - onToggleEmojiPicker, - onCloseEmojiPicker, - onEmojiSelect, - onShareClick, - addEmojiIcon, - shareIcon, - shareModalComponent, // ShareModal 컴포넌트를 props로 받음 -}) { - return ( - - - - - 추가 - - - - - - {shareModalComponent} - - - ); -} - +import React from "react"; +import styled from "styled-components"; +import EmojiPickerComponent from "./emoji-picker-component"; +import { + RollingHeaderEmojiEditButtonContainer, + RollingHeaderEmojiEditButton, + RollingHeaderEmojiEditButtonIcon, + RollingHeaderEmojiEditButtonText, + PerpendicularLineSecond, + RollingHeaderLinkShareButton, +} from "@/styles/rolling-page-styles"; + +const ShareButtonWrapper = styled.div` + position: relative; +`; + +/** + * 헤더 액션 버튼 컴포넌트 + * 책임: 이모지 추가 버튼과 공유 버튼 렌더링 + */ +export default function HeaderActionButtons({ + isEmojiPickerOpen, + onToggleEmojiPicker, + onCloseEmojiPicker, + onEmojiSelect, + onShareClick, + addEmojiIcon, + shareIcon, + shareModalComponent, // ShareModal 컴포넌트를 props로 받음 +}) { + return ( + + + + + 추가 + + + + + + {shareModalComponent} + + + ); +} diff --git a/src/components/rolling/participant-section.jsx b/src/components/rolling/participant-section.jsx index 7d27ee6..4dbb7b3 100644 --- a/src/components/rolling/participant-section.jsx +++ b/src/components/rolling/participant-section.jsx @@ -1,34 +1,36 @@ -import React from 'react'; -import { - RollingHeaderUserPeopleContainer, - RollingHeaderUserPeopleImages, -} from '@/styles/rolling-page-styles'; -import ProfileImageList from './profile-image-list'; -import ProfileOverflowBadge from './profile-overflow-badge'; -import ParticipantStats from './participant-stats'; -import useProfileImages from '@/hooks/use-profile-images'; - -/** - * 참여자 섹션 컴포넌트 - * 책임: 프로필 이미지와 참여자 통계를 조합하여 표시 - */ -export default function ParticipantSection({ profiles, maxVisible = 3 }) { - const { visibleProfiles, overflowCount, totalCount, hasOverflow } = - useProfileImages(profiles, maxVisible); - - return ( - - - {/* 보이는 프로필 이미지들 */} - - - {/* 오버플로우 뱃지 (+N) */} - {hasOverflow && } - - - {/* 참여자 통계 텍스트 */} - - - ); -} - +import React from "react"; +import { + RollingHeaderUserPeopleContainer, + RollingHeaderUserPeopleImages, +} from "@/styles/rolling-page-styles"; +import ProfileImageList from "./profile-image-list"; +import ProfileOverflowBadge from "./profile-overflow-badge"; +import ParticipantStats from "./participant-stats"; +import useProfileImages from "@/hooks/use-profile-images"; + +/** + * 참여자 섹션 컴포넌트 + * 책임: 프로필 이미지와 참여자 통계를 조합하여 표시 + * @param {Array} profiles - 표시할 프로필 목록 + * @param {number} totalCount - 전체 참여자 수 (messageCount) + * @param {number} maxVisible - 최대 표시 개수 + */ +export default function ParticipantSection({ profiles, totalCount, maxVisible = 3 }) { + const { overflowCount, hasOverflow } = useProfileImages(totalCount, profiles, maxVisible); + + + return ( + + + {/* 보이는 프로필 이미지들 */} + + + {/* 오버플로우 뱃지 (+N) */} + {hasOverflow && } + + + {/* 참여자 통계 텍스트 - API의 messageCount 사용 */} + + + ); +} diff --git a/src/components/rolling/participant-stats.jsx b/src/components/rolling/participant-stats.jsx index e52c89c..d6f0f7a 100644 --- a/src/components/rolling/participant-stats.jsx +++ b/src/components/rolling/participant-stats.jsx @@ -1,23 +1,18 @@ -import React from 'react'; -import { RollingHeaderUserPeopleState } from '@/styles/rolling-page-styles'; - -/** - * 참여자 통계 컴포넌트 - * 책임: 참여자 수 텍스트 표시 - */ -export default function ParticipantStats({ count }) { - if (count === 0) { - return ( - - 아직 작성한 사람이 없어요 - - ); - } - - return ( - - {count}명이 작성했어요! - - ); -} - +import React from "react"; +import { RollingHeaderUserPeopleState } from "@/styles/rolling-page-styles"; + +/** + * 참여자 통계 컴포넌트 + * 책임: 참여자 수 텍스트 표시 + */ +export default function ParticipantStats({ count }) { + if (count === 0) { + return 작성한 사람이 없어요!; + } + + return ( + + {count}명이 작성했어요! + + ); +} diff --git a/src/components/rolling/profile-image-list.jsx b/src/components/rolling/profile-image-list.jsx index 22e5551..a4e5921 100644 --- a/src/components/rolling/profile-image-list.jsx +++ b/src/components/rolling/profile-image-list.jsx @@ -1,25 +1,24 @@ -import React from 'react'; -import { RollingHeaderUserPeopleImage } from '@/styles/rolling-page-styles'; - -/** - * 프로필 이미지 리스트 컴포넌트 - * 책임: 프로필 이미지들을 렌더링 (래퍼 없이 순수 이미지만) - */ -export default function ProfileImageList({ profiles }) { - if (!profiles || profiles.length === 0) { - return null; - } - - return ( - <> - {profiles.map((profile, index) => ( - - ))} - - ); -} - +import React from "react"; +import { RollingHeaderUserPeopleImage } from "@/styles/rolling-page-styles"; + +/** + * 프로필 이미지 리스트 컴포넌트 + * 책임: 프로필 이미지들을 렌더링 (래퍼 없이 순수 이미지만) + */ +export default function ProfileImageList({ profiles }) { + if (!profiles || profiles.length === 0) { + return null; + } + + return ( + <> + {profiles.map((profile, index) => ( + + ))} + + ); +} diff --git a/src/components/rolling/profile-overflow-badge.jsx b/src/components/rolling/profile-overflow-badge.jsx index 2fe5249..9eb2d39 100644 --- a/src/components/rolling/profile-overflow-badge.jsx +++ b/src/components/rolling/profile-overflow-badge.jsx @@ -1,32 +1,31 @@ -import React from 'react'; -import styled from 'styled-components'; -import { colors } from '@/styles/colors'; -import { font } from '@/styles/font'; - -const OverflowBadge = styled.div` - width: 28px; - height: 28px; - border-radius: 140px; - border: 1.4px solid ${colors.gray[900]}; - background: ${colors.gray[200]}; - display: flex; - align-items: center; - justify-content: center; - position: relative; - margin-left: -10px; - ${font.regular12} - color: ${colors.gray[700]}; -`; - -/** - * 프로필 오버플로우 뱃지 컴포넌트 - * 책임: 추가 인원 수를 표시 (+N) - */ -export default function ProfileOverflowBadge({ count }) { - if (count <= 0) { - return null; - } - - return +{count}; -} - +import React from "react"; +import styled from "styled-components"; +import { colors } from "@/styles/colors"; +import { font } from "@/styles/font"; + +const OverflowBadge = styled.div` + width: 28px; + height: 28px; + border-radius: 140px; + border: 1.4px solid ${colors.gray[300]}; + background: #fff; + display: flex; + align-items: center; + justify-content: center; + position: relative; + margin-left: -10px; + ${font.regular12} + color: ${colors.gray[700]}; +`; + +/** + * 프로필 오버플로우 뱃지 컴포넌트 + * 책임: 추가 인원 수를 표시 (+N) + */ +export default function ProfileOverflowBadge({ count }) { + if (count <= 0) { + return null; + } + + return +{count}; +} diff --git a/src/components/rolling/rolling-page-header.jsx b/src/components/rolling/rolling-page-header.jsx new file mode 100644 index 0000000..90a6431 --- /dev/null +++ b/src/components/rolling/rolling-page-header.jsx @@ -0,0 +1,135 @@ +import React, { useState, useEffect } from "react"; +import ShareModal from "@/components/rolling/share-modal"; +import EmojiDisplayList from "@/components/rolling/emoji-display-list"; +import EmojiDropdown from "@/components/rolling/emoji-dropdown"; +import HeaderActionButtons from "@/components/rolling/header-action-buttons"; +import useEmojiManager from "@/hooks/use-emoji-manager"; +import { useReactions } from "@/hooks/use-reactions"; +import { RollingHeaderEmojiContainer } from "@/styles/rolling-page-styles"; + +/** + * 롤링 페이지 헤더 컴포넌트 + * 책임: 전체 헤더 구성 요소 조합 및 상태 관리 + */ +export default function RollingPageHeader({ + recipientId, + topReactions = [], + ArrowDownIcon, + AddEmojiIcon, + ShareIcon, +}) { + // UI 상태 관리 + const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false); + const [isEmojiDropdownOpen, setIsEmojiDropdownOpen] = useState(false); + const [isShareModalOpen, setIsShareModalOpen] = useState(false); + const [allReactions, setAllReactions] = useState([]); + + // API의 topReactions를 이모지 형태로 변환 + const initialEmojis = topReactions.map((reaction) => ({ + emoji: reaction.emoji, + count: reaction.count, + })); + + const { handleEmojiSelect, getSortedEmojis, getTopEmojis } = useEmojiManager(initialEmojis); + + // 리액션 API 훅 + const { reactions, fetchReactions, toggleReaction } = useReactions(recipientId); + + // 드롭다운 열릴 때 전체 리액션 불러오기 + useEffect(() => { + if (isEmojiDropdownOpen && reactions.length === 0) { + fetchReactions(); + } + }, [isEmojiDropdownOpen, reactions.length, fetchReactions]); + + // 전체 리액션 데이터 변환 + useEffect(() => { + if (reactions.length > 0) { + const converted = reactions.map((r) => ({ + emoji: r.emoji, + count: r.count, + })); + setAllReactions(converted); + } + }, [reactions]); + + // 이모지 선택 시 API 호출 + const handleEmojiAdd = async (emoji) => { + try { + await toggleReaction(emoji, "increase"); + handleEmojiSelect(emoji); + } catch (err) { + console.error("이모지 추가 실패", err); + } + }; + + // 이모지 피커 핸들러 + const toggleEmojiPicker = () => { + setIsEmojiPickerOpen(!isEmojiPickerOpen); + }; + + const closeEmojiPicker = () => { + setIsEmojiPickerOpen(false); + }; + + // 이모지 드롭다운 핸들러 + const toggleEmojiDropdown = () => { + setIsEmojiDropdownOpen(!isEmojiDropdownOpen); + }; + + const closeEmojiDropdown = () => { + setIsEmojiDropdownOpen(false); + }; + + // 공유 모달 핸들러 + const openShareModal = () => { + setIsShareModalOpen(true); + }; + + const closeShareModal = () => { + setIsShareModalOpen(false); + }; + + // 정렬된 이모지 및 상위 3개 추출 + const sortedEmojis = getSortedEmojis(); + const topThreeEmojis = getTopEmojis(3); + + // 드롭다운에 표시할 데이터: allReactions가 있으면 사용, 없으면 sortedEmojis 사용 + const dropdownEmojis = allReactions.length > 0 ? allReactions : sortedEmojis; + + // 현재 페이지 URL + const currentUrl = window.location.href; + + return ( + + {/* 상위 3개 이모지 표시 */} +
+ + {dropdownEmojis.length > 0 && ( + + )} +
+ + + {/* 이모지 추가 및 공유 버튼 */} + + } + /> +
+ ); +} diff --git a/src/components/rolling/share-button-group.jsx b/src/components/rolling/share-button-group.jsx index dddc833..b6c30c9 100644 --- a/src/components/rolling/share-button-group.jsx +++ b/src/components/rolling/share-button-group.jsx @@ -1,70 +1,68 @@ -import React from 'react'; -import styled from 'styled-components'; -import { colors } from '@/styles/colors'; -import { font } from '@/styles/font'; - -const ButtonGroup = styled.div` - position: absolute; - top: calc(100% + 8px); - right: 0; - width: 140px; - height: auto; - display: flex; - flex-direction: column; - background: white; - border-radius: 8px; - border: 1px solid ${colors.gray[300]}; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - z-index: 1001; - padding: 10px 0px; -`; - -const ShareButton = styled.button` - display: flex; - align-items: center; - justify-content: center; - gap: 12px; - background: transparent; - width: 100%; - height: 50px; - border: none; - cursor: pointer; - transition: all 0.2s; - ${font.regular16} - color: ${colors.gray[900]}; - - &:hover { - background: ${colors.gray[200]}; - border-color: ${colors.gray[400]}; - - } - - &:active { - transform: scale(0.98); - } -`; - -const KakaoButton = styled(ShareButton)` - background: #fee500; - border-color: #fee500; - color: #000000; - - &:hover { - background: #fdd835; - border-color: #fdd835; - } -`; - -/** - * 공유 버튼 그룹 컴포넌트 - * 책임: 공유 방법별 버튼 UI 렌더링 - */ -export default function ShareButtonGroup({ onKakaoShare, onCopyUrl }) { - return ( - - 카카오톡 공유 - URL 복사 - - ); -} - +import React from "react"; +import styled from "styled-components"; +import { colors } from "@/styles/colors"; +import { font } from "@/styles/font"; + +const ButtonGroup = styled.div` + position: absolute; + top: calc(100% + 8px); + right: 0; + width: 140px; + height: auto; + display: flex; + flex-direction: column; + background: white; + border-radius: 8px; + border: 1px solid ${colors.gray[300]}; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 1001; + padding: 10px 0px; +`; + +const ShareButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + background: transparent; + width: 100%; + height: 50px; + border: none; + cursor: pointer; + transition: all 0.2s; + ${font.regular16} + color: ${colors.gray[900]}; + + &:hover { + background: ${colors.gray[200]}; + border-color: ${colors.gray[400]}; + } + + &:active { + transform: scale(0.98); + } +`; + +const KakaoButton = styled(ShareButton)` + background: #fee500; + border-color: #fee500; + color: #000000; + + &:hover { + background: #fdd835; + border-color: #fdd835; + } +`; + +/** + * 공유 버튼 그룹 컴포넌트 + * 책임: 공유 방법별 버튼 UI 렌더링 + */ +export default function ShareButtonGroup({ onKakaoShare, onCopyUrl }) { + return ( + + 카카오톡 공유 + URL 복사 + + ); +} diff --git a/src/components/rolling/share-modal.jsx b/src/components/rolling/share-modal.jsx index a1a18dc..74c5fd0 100644 --- a/src/components/rolling/share-modal.jsx +++ b/src/components/rolling/share-modal.jsx @@ -1,55 +1,54 @@ -import React from 'react'; -import styled from 'styled-components'; - -import ShareButtonGroup from './share-button-group'; -import useKakaoSdk from '@/hooks/use-kakao-sdk'; -import useShareActions from '@/hooks/use-share-actions'; - -const Overlay = styled.div` - position: fixed; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - background: transparent; - z-index: 999; -`; - -/** - * 공유 모달 컴포넌트 - * 책임: 공유 모달 UI 및 공유 액션 연결 - */ -export default function ShareModal({ isOpen, onClose, shareUrl }) { - // 카카오 SDK 초기화 - useKakaoSdk(); - - // 공유 기능 훅 - const { copyToClipboard, shareToKakao } = useShareActions(); - - // URL 복사 핸들러 - const handleCopyUrl = async () => { - const success = await copyToClipboard(shareUrl); - if (success) { - onClose(); - } - }; - - // 카카오톡 공유 핸들러 - const handleKakaoShare = () => { - shareToKakao(shareUrl); - }; - - if (!isOpen) return null; - - return ( - <> - - e.stopPropagation()} - onKakaoShare={handleKakaoShare} - onCopyUrl={handleCopyUrl} - /> - - ); -} - +import React from "react"; +import styled from "styled-components"; + +import ShareButtonGroup from "./share-button-group"; +import useKakaoSdk from "@/hooks/use-kakao-sdk"; +import useShareActions from "@/hooks/use-share-actions"; + +const Overlay = styled.div` + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: transparent; + z-index: 999; +`; + +/** + * 공유 모달 컴포넌트 + * 책임: 공유 모달 UI 및 공유 액션 연결 + */ +export default function ShareModal({ isOpen, onClose, shareUrl }) { + // 카카오 SDK 초기화 + useKakaoSdk(); + + // 공유 기능 훅 + const { copyToClipboard, shareToKakao } = useShareActions(); + + // URL 복사 핸들러 + const handleCopyUrl = async () => { + const success = await copyToClipboard(shareUrl); + if (success) { + onClose(); + } + }; + + // 카카오톡 공유 핸들러 + const handleKakaoShare = () => { + shareToKakao(shareUrl); + }; + + if (!isOpen) return null; + + return ( + <> + + e.stopPropagation()} + onKakaoShare={handleKakaoShare} + onCopyUrl={handleCopyUrl} + /> + + ); +} diff --git a/src/hooks/use-cards.js b/src/hooks/use-cards.js index dcf0bfc..f9885aa 100644 --- a/src/hooks/use-cards.js +++ b/src/hooks/use-cards.js @@ -1,36 +1,29 @@ -import { useMemo } from 'react'; - - -/** - * 카드 섹션 컴포넌트 - * 책임: 카드 데이터를 처리하고 표시 - */ -export default function useCards(cards, maxVisible = 6) { - const processedData = useMemo(() => { - if (!cards || cards.length === 0) { - return { - visibleCards: [], - overflowCount: 0, - totalCount: 0, - hasOverflow: false, - }; - } - - const totalCount = cards.length; - const hasOverflow = totalCount > maxVisible; - - const visibleCount = hasOverflow ? maxVisible - 1 : maxVisible; - const visibleCards = cards.slice(0, visibleCount); - const overflowCount = hasOverflow ? totalCount - visibleCount : 0; - - return { - visibleCards, - overflowCount, - totalCount, - hasOverflow, - }; - }, [cards, maxVisible]); - - return processedData; -} - +import { useMemo } from "react"; + +/** + * 카드 데이터 처리 커스텀 훅 + * 책임: 카드 목록 데이터 가공 및 표시 개수 제한 + */ +export default function useCards(cards, maxVisible = 6) { + const processedData = useMemo(() => { + if (!cards || cards.length === 0) { + return { + visibleCards: [], + totalCount: 0, + hasMore: false, + }; + } + + const totalCount = cards.length; + const hasMore = totalCount > maxVisible; + const visibleCards = cards.slice(0, maxVisible); + + return { + visibleCards, + totalCount, + hasMore, + }; + }, [cards, maxVisible]); + + return processedData; +} diff --git a/src/hooks/use-delete-actions.js b/src/hooks/use-delete-actions.js new file mode 100644 index 0000000..b463770 --- /dev/null +++ b/src/hooks/use-delete-actions.js @@ -0,0 +1,68 @@ +import { useCallback } from "react"; +import { useNavigate } from "react-router"; +import { deleteRecipient } from "@/api/rolling-page-api"; +import { deleteMessage } from "@/api/rolling-page-api"; +import { useToast } from "@/hooks/use-toast"; + +/** + * 삭제 액션 관리 커스텀 훅 + * 책임: 수신자 및 메시지 삭제 로직 처리 + */ +export function useDeleteActions() { + const navigate = useNavigate(); + const showToast = useToast(); + + /** + * 롤링 페이퍼 전체 삭제 + */ + const handleDeleteRecipient = useCallback( + async (recipientId) => { + try { + await deleteRecipient(recipientId); + showToast.delete("롤링 페이퍼가 삭제되었습니다."); + + // 삭제 후 메인 페이지로 이동 + setTimeout(() => { + navigate("/"); + }, 1000); + + return true; + } catch (err) { + console.error("롤링 페이퍼 삭제 실패:", err); + return false; + } + }, + [navigate, showToast], + ); + + /** + * 개별 메시지 삭제 + */ + const handleDeleteMessage = useCallback( + async (messageId, onSuccess) => { + try { + await deleteMessage(messageId); + showToast.delete("메시지가 삭제되었습니다."); + + // 삭제 성공 시 콜백 실행 (목록 갱신 등) + if (onSuccess) { + onSuccess(); + } + + return true; + } catch (err) { + console.error("메시지 삭제 실패:", err); + return false; + } + }, + [showToast], + ); + + return { + handleDeleteRecipient, + handleDeleteMessage, + }; +} + +export default useDeleteActions; + diff --git a/src/hooks/use-edit-mode.js b/src/hooks/use-edit-mode.js new file mode 100644 index 0000000..1048f93 --- /dev/null +++ b/src/hooks/use-edit-mode.js @@ -0,0 +1,14 @@ +import { useLocation } from "react-router"; + +/** + * 편집 모드 확인 커스텀 훅 + * 책임: URL 경로를 확인하여 편집 모드 여부 판단 + */ +export default function useEditMode() { + const location = useLocation(); + + // URL이 /edit으로 끝나면 편집 모드 + const isEditMode = location.pathname.endsWith("/edit"); + + return isEditMode; +} diff --git a/src/hooks/use-emoji-manager.js b/src/hooks/use-emoji-manager.js index c47018f..34553ae 100644 --- a/src/hooks/use-emoji-manager.js +++ b/src/hooks/use-emoji-manager.js @@ -1,41 +1,40 @@ -import { useState } from 'react'; - -/** - * 이모지 관리 커스텀 훅 - * 책임: 이모지 상태 관리 및 비즈니스 로직 처리 - */ -export default function useEmojiManager(initialEmojis = []) { - const [selectedEmojis, setSelectedEmojis] = useState(initialEmojis); - - const handleEmojiSelect = (emoji) => { - const existingEmojiIndex = selectedEmojis.findIndex(item => item.emoji === emoji); - - if (existingEmojiIndex !== -1) { - // 이미 존재하는 이모지면 카운트 증가 - const updatedEmojis = [...selectedEmojis]; - updatedEmojis[existingEmojiIndex].count += 1; - setSelectedEmojis(updatedEmojis); - } else { - // 새로운 이모지면 추가 - setSelectedEmojis([...selectedEmojis, { emoji, count: 1 }]); - } - }; - - // 카운트 순으로 정렬 - const getSortedEmojis = () => { - return [...selectedEmojis].sort((a, b) => b.count - a.count); - }; - - // 상위 N개 추출 - const getTopEmojis = (count) => { - return getSortedEmojis().slice(0, count); - }; - - return { - selectedEmojis, - handleEmojiSelect, - getSortedEmojis, - getTopEmojis, - }; -} - +import { useState } from "react"; + +/** + * 이모지 관리 커스텀 훅 + * 책임: 이모지 상태 관리 및 비즈니스 로직 처리 + */ +export default function useEmojiManager(initialEmojis = []) { + const [selectedEmojis, setSelectedEmojis] = useState(initialEmojis); + + const handleEmojiSelect = (emoji) => { + const existingEmojiIndex = selectedEmojis.findIndex((item) => item.emoji === emoji); + + if (existingEmojiIndex !== -1) { + // 이미 존재하는 이모지면 카운트 증가 + const updatedEmojis = [...selectedEmojis]; + updatedEmojis[existingEmojiIndex].count += 1; + setSelectedEmojis(updatedEmojis); + } else { + // 새로운 이모지면 추가 + setSelectedEmojis([...selectedEmojis, { emoji, count: 1 }]); + } + }; + + // 카운트 순으로 정렬 + const getSortedEmojis = () => { + return [...selectedEmojis].sort((a, b) => b.count - a.count); + }; + + // 상위 N개 추출 + const getTopEmojis = (count) => { + return getSortedEmojis().slice(0, count); + }; + + return { + selectedEmojis, + handleEmojiSelect, + getSortedEmojis, + getTopEmojis, + }; +} diff --git a/src/hooks/use-infinite-recipients.js b/src/hooks/use-infinite-recipients.js new file mode 100644 index 0000000..f0dfc87 --- /dev/null +++ b/src/hooks/use-infinite-recipients.js @@ -0,0 +1,81 @@ +import { useState, useCallback } from "react"; +import { getRecipientMessages } from "@/api/rolling-page-api"; + +/** + * 무한 스크롤을 위한 수신자 메시지 조회 커스텀 훅 + * 책임: 메시지 무한 스크롤 데이터 관리 + */ +export function useInfiniteRecipientMessages(recipientId, isEditMode = false) { + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [hasMore, setHasMore] = useState(true); + const [offset, setOffset] = useState(0); + const limit = 6; // 이후 로드할 메시지 개수 + const initialLimit = isEditMode ? 6 : 5; // 뷰어 모드는 추가하기 카드 때문에 5개 + + // 초기 데이터 로드 + const fetchInitialData = useCallback(async () => { + if (!recipientId) return; + + setLoading(true); + setError(null); + setOffset(0); + + try { + const data = await getRecipientMessages(recipientId, { + limit: initialLimit, + offset: 0 + }); + // results는 이미 최신순으로 정렬되어 있음 + setMessages(data.results || []); + setHasMore(data.next !== null); + setOffset(initialLimit); + } catch (err) { + setError(err.message || "메시지를 불러오는데 실패했습니다."); + setHasMore(false); + } finally { + setLoading(false); + } + }, [recipientId, initialLimit]); + + // 더 많은 데이터 로드 (무한 스크롤) + const fetchMoreData = useCallback(async () => { + if (loading || !hasMore) return; + + setLoading(true); + + try { + const data = await getRecipientMessages(recipientId, { limit, offset }); + + // 기존 메시지에 새 메시지 추가 + setMessages((prev) => [...prev, ...(data.results || [])]); + setHasMore(data.next !== null); + setOffset((prev) => prev + limit); + } catch (err) { + setError(err.message || "추가 메시지를 불러오는데 실패했습니다."); + setHasMore(false); + } finally { + setLoading(false); + } + }, [loading, hasMore, offset, recipientId]); + + const refresh = useCallback(() => { + setMessages([]); + setOffset(0); + setHasMore(true); + fetchInitialData(); + }, [fetchInitialData]); + + return { + messages, + loading, + error, + hasMore, + fetchInitialData, + fetchMoreData, + refresh, + }; +} + +export default useInfiniteRecipientMessages; diff --git a/src/hooks/use-kakao-sdk.js b/src/hooks/use-kakao-sdk.js index 7a8ac58..18d0104 100644 --- a/src/hooks/use-kakao-sdk.js +++ b/src/hooks/use-kakao-sdk.js @@ -1,41 +1,38 @@ -import { useEffect } from 'react'; - -const KAKAO_KEY = import.meta.env.VITE_KAKAO_KEY; - -/** - * 카카오 SDK 초기화 커스텀 훅 - * 책임: 카카오 SDK 스크립트 로드 및 초기화 - */ -export default function useKakaoSdk() { - - useEffect(() => { - // 이미 SDK가 로드되어 있으면 초기화만 수행 - if (window.Kakao) { - if (!window.Kakao.isInitialized()) { - window.Kakao.init(KAKAO_KEY); - } - return; - } - - // SDK 스크립트 동적 로드 - const script = document.createElement('script'); - script.src = 'https://t1.kakaocdn.net/kakao_js_sdk/2.7.7/kakao.min.js'; - script.integrity = 'sha384-tJkjbtDbvoxO+diRuDtwRO9JXR7pjWnfjfRn5ePUpl7e7RJCxKCwwnfqUAdXh53p'; - script.crossOrigin = 'anonymous'; - script.async = true; - - script.onload = () => { - if (window.Kakao && !window.Kakao.isInitialized()) { - window.Kakao.init(KAKAO_KEY); - } - }; - - document.head.appendChild(script); - - }, []); - - return { - isKakaoReady: window.Kakao?.isInitialized() || false, - }; -} - +import { useEffect } from "react"; + +const KAKAO_KEY = import.meta.env.VITE_KAKAO_KEY; + +/** + * 카카오 SDK 초기화 커스텀 훅 + * 책임: 카카오 SDK 스크립트 로드 및 초기화 + */ +export default function useKakaoSdk() { + useEffect(() => { + // 이미 SDK가 로드되어 있으면 초기화만 수행 + if (window.Kakao) { + if (!window.Kakao.isInitialized()) { + window.Kakao.init(KAKAO_KEY); + } + return; + } + + // SDK 스크립트 동적 로드 + const script = document.createElement("script"); + script.src = "https://t1.kakaocdn.net/kakao_js_sdk/2.7.7/kakao.min.js"; + script.integrity = "sha384-tJkjbtDbvoxO+diRuDtwRO9JXR7pjWnfjfRn5ePUpl7e7RJCxKCwwnfqUAdXh53p"; + script.crossOrigin = "anonymous"; + script.async = true; + + script.onload = () => { + if (window.Kakao && !window.Kakao.isInitialized()) { + window.Kakao.init(KAKAO_KEY); + } + }; + + document.head.appendChild(script); + }, []); + + return { + isKakaoReady: window.Kakao?.isInitialized() || false, + }; +} diff --git a/src/hooks/use-profile-images.js b/src/hooks/use-profile-images.js index 8e83833..34f011d 100644 --- a/src/hooks/use-profile-images.js +++ b/src/hooks/use-profile-images.js @@ -1,36 +1,35 @@ -import { useMemo } from 'react'; - -/** - * 프로필 이미지 데이터 처리 커스텀 훅 - * 책임: 프로필 이미지 데이터 가공 및 오버플로우 계산 - */ -export default function useProfileImages(profiles, maxVisible = 3) { - const processedData = useMemo(() => { - if (!profiles || profiles.length === 0) { - return { - visibleProfiles: [], - overflowCount: 0, - totalCount: 0, - hasOverflow: false, - }; - } - - const totalCount = profiles.length; - const hasOverflow = totalCount > maxVisible; - - // 오버플로우가 있으면 마지막 자리는 +N 표시용으로 비움 - const visibleCount = hasOverflow ? maxVisible - 1 : maxVisible; - const visibleProfiles = profiles.slice(0, visibleCount); - const overflowCount = hasOverflow ? totalCount - visibleCount : 0; - - return { - visibleProfiles, - overflowCount, - totalCount, - hasOverflow, - }; - }, [profiles, maxVisible]); - - return processedData; -} - +import { useMemo } from "react"; + +/** + * 프로필 이미지 데이터 처리 커스텀 훅 + * 책임: 프로필 이미지 데이터 가공 및 오버플로우 계산 + */ +export default function useProfileImages(totalCount, profiles, maxVisible = 3) { + const processedData = useMemo(() => { + if (!profiles || profiles.length === 0) { + return { + visibleProfiles: [], + overflowCount: 0, + totalCount: 0, + hasOverflow: false, + }; + } + + + const hasOverflow = totalCount > maxVisible; + + // 오버플로우가 있으면 마지막 자리는 +N 표시용으로 비움 + const visibleCount = 3 + const visibleProfiles = profiles.slice(0, visibleCount); + const overflowCount = hasOverflow ? totalCount - visibleCount : 0; + + return { + visibleProfiles, + overflowCount, + totalCount, + hasOverflow, + }; + }, [totalCount, profiles, maxVisible]); + + return processedData; +} diff --git a/src/hooks/use-reactions.js b/src/hooks/use-reactions.js new file mode 100644 index 0000000..3e6d932 --- /dev/null +++ b/src/hooks/use-reactions.js @@ -0,0 +1,69 @@ +import { useState, useCallback } from "react"; +import { getReactions, addReaction } from "@/api/rolling-page-api"; + +/** + * 리액션 관리 커스텀 훅 + * 책임: 리액션 데이터 관리 및 API 호출 + */ +export function useReactions(recipientId) { + const [reactions, setReactions] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // 모든 리액션 조회 (최대 8개) + const fetchReactions = useCallback(async () => { + if (!recipientId) return; + + setLoading(true); + setError(null); + + try { + const data = await getReactions(recipientId, { limit: 8, offset: 0 }); + setReactions(data.results || []); + } catch (err) { + setError(err.message || "리액션을 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }, [recipientId]); + + // 리액션 추가/감소 + const toggleReaction = useCallback( + async (emoji, type = "increase") => { + if (!recipientId) return; + + try { + const result = await addReaction(recipientId, { emoji, type }); + + // 로컬 상태 업데이트 + setReactions((prev) => { + const existing = prev.find((r) => r.emoji === emoji); + if (existing) { + return prev.map((r) => + r.emoji === emoji ? { ...r, count: result.count } : r, + ); + } else { + return [...prev, result]; + } + }); + + return result; + } catch (err) { + setError(err.message || "리액션 추가에 실패했습니다."); + throw err; + } + }, + [recipientId], + ); + + return { + reactions, + loading, + error, + fetchReactions, + toggleReaction, + }; +} + +export default useReactions; + diff --git a/src/hooks/use-recipients.js b/src/hooks/use-recipients.js new file mode 100644 index 0000000..da78758 --- /dev/null +++ b/src/hooks/use-recipients.js @@ -0,0 +1,51 @@ +import { useState, useEffect, useCallback } from "react"; +import { getRecipientById } from "@/api/rolling-page-api"; + + +/** + * 특정 유저 상세 조회 커스텀 훅 + * 책임: 단일 유저 데이터 관리 및 API 호출 + */ +export function useRecipient(recipientId, autoFetch = true) { + const [recipient, setRecipient] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchRecipient = useCallback(async () => { + if (!recipientId) return; + + setLoading(true); + setError(null); + + try { + const data = await getRecipientById(recipientId); + setRecipient(data); + } catch (err) { + setError(err.message || "수신자 정보를 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }, [recipientId]); + + useEffect(() => { + if (autoFetch && recipientId) { + fetchRecipient(); + } + }, [autoFetch, recipientId, fetchRecipient]); + + const refresh = useCallback(() => { + fetchRecipient(); + }, [fetchRecipient]); + + return { + recipient, + loading, + error, + refresh, + fetchRecipient, + }; +} + + + + diff --git a/src/hooks/use-share-actions.js b/src/hooks/use-share-actions.js index 03bd9ea..a175c2f 100644 --- a/src/hooks/use-share-actions.js +++ b/src/hooks/use-share-actions.js @@ -1,51 +1,56 @@ -import { useCallback } from 'react'; -import { useToast } from '@/hooks/use-toast'; - -/** - * 공유 기능 커스텀 훅 - * 책임: URL 복사 및 카카오톡 공유 비즈니스 로직 처리 - */ -export default function useShareActions() { - const showToast = useToast(); - - /** - * URL을 클립보드에 복사 - */ - const copyToClipboard = useCallback(async (url) => { - try { - await navigator.clipboard.writeText(url); - showToast.success('URL이 복사되었습니다.'); - return true; - } catch (err) { - console.error('URL 복사 실패:', err); - return false; - } - }, [showToast]); - - /** - * 카카오톡으로 공유 - */ - const shareToKakao = useCallback((url) => { - if (!window.Kakao) { - return false; - } - - try { - window.Kakao.Share.sendScrap({ - requestUrl: url, - }); - showToast.success('카카오톡으로 공유되었습니다.'); - - return true; - } catch (err) { - console.error('카카오톡 공유 실패:', err); - return false; - } - }, [showToast]); - - return { - copyToClipboard, - shareToKakao, - }; -} - +import { useCallback } from "react"; +import { useToast } from "@/hooks/use-toast"; + +/** + * 공유 기능 커스텀 훅 + * 책임: URL 복사 및 카카오톡 공유 비즈니스 로직 처리 + */ +export default function useShareActions() { + const showToast = useToast(); + + /** + * URL을 클립보드에 복사 + */ + const copyToClipboard = useCallback( + async (url) => { + try { + await navigator.clipboard.writeText(url); + showToast.success("URL이 복사되었습니다."); + return true; + } catch (err) { + console.error("URL 복사 실패:", err); + return false; + } + }, + [showToast], + ); + + /** + * 카카오톡으로 공유 + */ + const shareToKakao = useCallback( + (url) => { + if (!window.Kakao) { + return false; + } + + try { + window.Kakao.Share.sendScrap({ + requestUrl: url, + }); + showToast.success("카카오톡으로 공유되었습니다."); + + return true; + } catch (err) { + console.error("카카오톡 공유 실패:", err); + return false; + } + }, + [showToast], + ); + + return { + copyToClipboard, + shareToKakao, + }; +} diff --git a/src/main.jsx b/src/main.jsx index 0a09416..66e917e 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -8,5 +8,5 @@ createRoot(document.getElementById("root")).render( - + , ); diff --git a/src/pages/main-page.jsx b/src/pages/main-page.jsx index de50804..63594a3 100644 --- a/src/pages/main-page.jsx +++ b/src/pages/main-page.jsx @@ -176,17 +176,13 @@ export default function MainPage() { Point. 01 - - 누구나 손쉽게, 온라인 롤링 페이퍼를 만들 수 있어요 - + 누구나 손쉽게, 온라인 롤링 페이퍼를 만들 수 있어요 로그인 없이 자유롭게 만들어요. Point. 02 서로에게 이모지로 감정을 표현해보세요 - - 롤링 페이퍼에 이모지를 추가할 수 있어요. - + 롤링 페이퍼에 이모지를 추가할 수 있어요. diff --git a/src/pages/message-page.jsx b/src/pages/message-page.jsx index 0c9ae52..066eced 100644 --- a/src/pages/message-page.jsx +++ b/src/pages/message-page.jsx @@ -102,7 +102,9 @@ export const SelectableImageItem = styled.li` overflow: hidden; cursor: pointer; flex-shrink: 0; /* 크기 고정 */ - transition: transform 0.2s, border 0.2s; + transition: + transform 0.2s, + border 0.2s; border: 2px solid transparent; ${({ isSelected }) => @@ -177,11 +179,7 @@ function MessagePage() { {/* From. 입력 필드 */} From. - + {/* 에러 메시지 표시 */} {hasError && "값을 입력해 주세요."} @@ -195,10 +193,7 @@ function MessagePage() { {selectableImages.map((image) => ( - + {`프로필 ))} @@ -209,11 +204,7 @@ function MessagePage() { {/* 상대와의 관계 드롭다운*/} 상대와의 관계 - + diff --git a/src/pages/post-page.jsx b/src/pages/post-page.jsx index b505096..b0446ac 100644 --- a/src/pages/post-page.jsx +++ b/src/pages/post-page.jsx @@ -198,9 +198,7 @@ export default function PostPage() { 배경화면을 선택해 주세요. - - 컬러를 선택하거나, 이미지를 선택할 수 있습니다. - + 컬러를 선택하거나, 이미지를 선택할 수 있습니다. 컬러 이미지 diff --git a/src/pages/rolling-page-edit.jsx b/src/pages/rolling-page-edit.jsx deleted file mode 100644 index 8cea62b..0000000 --- a/src/pages/rolling-page-edit.jsx +++ /dev/null @@ -1,58 +0,0 @@ -import React, { useState } from 'react'; -import { - RollingHeaderContainer, - RollingHeaderUserInfo, - RollingHeaderRightContainer, - PerpendicularLineFirst, - RollingPageContainer, - - -} from "@/styles/rolling-page-styles"; -import RollingPageHeader from "@/pages/rolling-page-head"; -import ParticipantSection from "@/components/rolling/participant-section"; -import ArrowDownIcon from "@/assets/icons/arrow-down.svg"; -import AddEmojiIcon from "@/assets/icons/add-emoji.svg"; -import ShareIcon from "@/assets/icons/share.svg"; -import CardContents from "@/components/rolling/card-contents"; - -export default function RollingPage() { - // TODO: 실제로는 API에서 받아올 데이터 - // 임시 데이터 (나중에 API 호출로 대체) - const [profiles] = useState([ - { id: 1, name: '김철수', profileImageURL: 'https://via.placeholder.com/28' }, - { id: 2, name: '이영희', profileImageURL: 'https://via.placeholder.com/28' }, - { id: 3, name: '박민수', profileImageURL: 'https://via.placeholder.com/28' }, - - ]); - - - return ( - <> - - - To. Ashley Kim - - - - {/* 참여자 프로필 섹션 */} - - - - - {/* 이모지 및 공유 헤더 */} - - - - - - - - - ); -} - - diff --git a/src/pages/rolling-page-head.jsx b/src/pages/rolling-page-head.jsx deleted file mode 100644 index f165398..0000000 --- a/src/pages/rolling-page-head.jsx +++ /dev/null @@ -1,106 +0,0 @@ -import React, { useState } from 'react'; -import ShareModal from '@/components/rolling/share-modal'; -import EmojiDisplayList from '@/components/rolling/emoji-display-list'; -import EmojiDropdown from '@/components/rolling/emoji-dropdown'; -import HeaderActionButtons from '@/components/rolling/header-action-buttons'; -import useEmojiManager from '@/hooks/use-emoji-manager'; -import { RollingHeaderImojiContainer } from '@/styles/rolling-page-styles'; - -/** - * 롤링 페이지 헤더 컴포넌트 - * 책임: 전체 헤더 구성 요소 조합 및 상태 관리 - */ -export default function RollingPageHeader({ - ArrowDownIcon, - AddEmojiIcon, - ShareIcon -}) { - // UI 상태 관리 - const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false); - const [isEmojiDropdownOpen, setIsEmojiDropdownOpen] = useState(false); - const [isShareModalOpen, setIsShareModalOpen] = useState(false); - - // 이모지 상태 및 로직 관리 - const initialEmojis = [ - { emoji: '😘', count: 12 }, - { emoji: '😍', count: 8 }, - { emoji: '👍', count: 15 }, - { emoji: '🎉', count: 5 }, - { emoji: '❤️', count: 20 }, - { emoji: '😂', count: 3 }, - { emoji: '🔥', count: 7 } - ]; - - const { handleEmojiSelect, getSortedEmojis, getTopEmojis } = useEmojiManager(initialEmojis); - - // 이모지 피커 핸들러 - const toggleEmojiPicker = () => { - setIsEmojiPickerOpen(!isEmojiPickerOpen); - }; - - const closeEmojiPicker = () => { - setIsEmojiPickerOpen(false); - }; - - // 이모지 드롭다운 핸들러 - const toggleEmojiDropdown = () => { - setIsEmojiDropdownOpen(!isEmojiDropdownOpen); - }; - - const closeEmojiDropdown = () => { - setIsEmojiDropdownOpen(false); - }; - - // 공유 모달 핸들러 - const openShareModal = () => { - setIsShareModalOpen(true); - }; - - const closeShareModal = () => { - setIsShareModalOpen(false); - }; - - // 정렬된 이모지 및 상위 3개 추출 - const sortedEmojis = getSortedEmojis(); - const topThreeEmojis = getTopEmojis(3); - const hasMoreEmojis = sortedEmojis.length > 3; - - // 현재 페이지 URL - const currentUrl = window.location.href; - - return ( - - {/* 상위 3개 이모지 표시 */} - - - {/* 더 많은 이모지가 있을 경우 드롭다운 */} - {hasMoreEmojis && ( - - )} - - {/* 이모지 추가 및 공유 버튼 */} - - } - /> - - ); -} \ No newline at end of file diff --git a/src/pages/rolling-page.jsx b/src/pages/rolling-page.jsx index 9c43cba..4661aba 100644 --- a/src/pages/rolling-page.jsx +++ b/src/pages/rolling-page.jsx @@ -1,58 +1,126 @@ -import React, { useState } from 'react'; -import { - RollingHeaderContainer, - RollingHeaderUserInfo, - RollingHeaderRightContainer, - PerpendicularLineFirst, - RollingPageContainer, - - -} from "@/styles/rolling-page-styles"; -import RollingPageHeader from "@/pages/rolling-page-head"; -import ParticipantSection from "@/components/rolling/participant-section"; -import ArrowDownIcon from "@/assets/icons/arrow-down.svg"; -import AddEmojiIcon from "@/assets/icons/add-emoji.svg"; -import ShareIcon from "@/assets/icons/share.svg"; -import CardContents from "@/components/rolling/card-contents"; - -export default function RollingPage() { - // TODO: 실제로는 API에서 받아올 데이터 - // 임시 데이터 (나중에 API 호출로 대체) - const [profiles] = useState([ - { id: 1, name: '김철수', profileImageURL: 'https://via.placeholder.com/28' }, - { id: 2, name: '이영희', profileImageURL: 'https://via.placeholder.com/28' }, - { id: 3, name: '박민수', profileImageURL: 'https://via.placeholder.com/28' }, - - ]); - - - return ( - <> - - - To. Ashley Kim - - - - {/* 참여자 프로필 섹션 */} - - - - - {/* 이모지 및 공유 헤더 */} - - - - - - - - - ); -} - - +import React, { useState } from "react"; +import { useParams } from "react-router"; +import { + RollingHeaderContainer, + RollingHeaderUserInfo, + RollingHeaderRightContainer, + PerpendicularLineFirst, + RollingPageContainer, + CardPageDeleteButton, + CardContainerWrapper, +} from "@/styles/rolling-page-styles"; +import RollingPageHeader from "@/components/rolling/rolling-page-header"; +import ParticipantSection from "@/components/rolling/participant-section"; +import CardContents from "@/components/rolling/card-contents"; +import DeleteConfirmModal from "@/components/rolling/delete-confirm-modal"; +import useEditMode from "@/hooks/use-edit-mode"; +import { useRecipient } from "@/hooks/use-recipients"; +import { useDeleteActions } from "@/hooks/use-delete-actions"; +import ArrowDownIcon from "@/assets/icons/arrow-down.svg"; +import AddEmojiIcon from "@/assets/icons/add-emoji.svg"; +import ShareIcon from "@/assets/icons/share.svg"; + +export default function RollingPage() { + // URL 파라미터에서 recipientId 가져오기 (/post/:id) + const { id } = useParams(); + const recipientId = Number(id); + + // 편집 모드 확인 (URL이 /edit으로 끝나는지) + const isEditMode = useEditMode(); + + // API에서 수신자 데이터 가져오기 + const { recipient, error } = useRecipient(recipientId); + + // 삭제 액션 훅 + const { handleDeleteRecipient } = useDeleteActions(); + + // 페이지 삭제 확인 모달 상태 + const [isDeletePageModalOpen, setIsDeletePageModalOpen] = useState(false); + + // recipientId가 없거나 유효하지 않은 경우 + if (!recipientId || isNaN(recipientId)) { + return
잘못된 페이지 주소입니다.
; + } + + + if (error) { + return
에러가 발생했습니다: {error}
; + } + + if (!recipient) { + return
데이터를 불러올 수 없습니다.
; + } + + // recentMessages에서 프로필 데이터 추출 (최신순 3개) + const profiles = recipient.recentMessages?.map((msg, index) => ({ + id: msg.id || index, + name: msg.sender, + profileImageURL: msg.profileImageURL, + })) || []; + + // 페이지 삭제 핸들러 + const handleOpenDeletePageModal = () => { + setIsDeletePageModalOpen(true); + }; + + const handleCloseDeletePageModal = () => { + setIsDeletePageModalOpen(false); + }; + + const handleConfirmDeletePage = () => { + handleDeleteRecipient(recipientId); + }; + + return ( + <> + + To. {recipient.name} + + + {/* 참여자 프로필 섹션 - messageCount 전달 */} + + + + + {/* 이모지 및 공유 헤더 - topReactions 전달 */} + + + + + + + {/* 편집 모드일 때만 페이지 삭제 버튼 표시 */} + + {isEditMode && ( + + 삭제하기 + + )} + + + + + + {/* 페이지 전체 삭제 확인 모달 */} + + + ); +} diff --git a/src/pages/toast-test-page.jsx b/src/pages/toast-test-page.jsx index bbe8e3b..6333b29 100644 --- a/src/pages/toast-test-page.jsx +++ b/src/pages/toast-test-page.jsx @@ -15,12 +15,8 @@ export default function ToastTestPage() { return (

Toast 테스트 페이지 (5초 후 자동 사라짐)

- - + +
); } diff --git a/src/styles/global-style.js b/src/styles/global-style.js index 44bbb3d..19ad8d1 100644 --- a/src/styles/global-style.js +++ b/src/styles/global-style.js @@ -12,11 +12,11 @@ export const GlobalStyle = createGlobalStyle`${css` #root { margin: 0; padding: 0; + } :root { - --font-family: "Pretendard", -apple-system, BlinkMacSystemFont, system-ui, - Roboto, sans-serif; + --font-family: "Pretendard", -apple-system, BlinkMacSystemFont, system-ui, Roboto, sans-serif; } body { diff --git a/src/styles/head-nav-style.js b/src/styles/head-nav-style.js index 17d8de0..ebb611d 100644 --- a/src/styles/head-nav-style.js +++ b/src/styles/head-nav-style.js @@ -1,18 +1,16 @@ -import styled from "styled-components"; -import { colors } from "@/styles/colors"; -import media from "@/styles/media"; - - -export const HeadNavContainer = styled.div` - display: flex; - justify-content: space-between; - align-items: center; - height: 65px; - background-color: #fff; - border-bottom: 1px solid ${colors.gray[200]}; - - ${media.small` - display: none; - `} - -`; +import styled from "styled-components"; +import { colors } from "@/styles/colors"; +import media from "@/styles/media"; + +export const HeadNavContainer = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + height: 65px; + background-color: #fff; + border-bottom: 1px solid ${colors.gray[200]}; + + ${media.small` + display: none; + `} +`; diff --git a/src/styles/message-page.js b/src/styles/message-page.js index dafb8ad..493b0eb 100644 --- a/src/styles/message-page.js +++ b/src/styles/message-page.js @@ -91,8 +91,7 @@ export const SelectableImageItem = styled.li` border-radius: 50%; overflow: hidden; cursor: pointer; - border: 2px solid - ${(props) => (props.isSelected ? COLOR_PRIMARY : "transparent")}; + border: 2px solid ${(props) => (props.isSelected ? COLOR_PRIMARY : "transparent")}; transition: border 0.2s; & img { diff --git a/src/styles/rolling-page-styles.js b/src/styles/rolling-page-styles.js index d2162db..4f201e5 100644 --- a/src/styles/rolling-page-styles.js +++ b/src/styles/rolling-page-styles.js @@ -1,480 +1,558 @@ -import styled from "styled-components"; -import { colors } from "@/styles/colors"; -import { font } from "@/styles/font"; -import ShareIcon from "@/assets/icons/share.svg"; -import media from "@/styles/media"; -import EditIcon from "@/assets/icons/plus.svg"; -import DeleteIcon from "@/assets/icons/deleted.svg"; - - -//최상단헤더 컨테이너 -export const RollingHeaderContainer = styled.div` - display: flex; - justify-content: space-between; - align-items: center; - width: 1200px; - margin: 0 auto; - padding: 13px 20px; - height: 68px; - background-color: rgba(255, 255, 255, 1); - gap: 20px; - - ${media.large` - width: 1200px; - height: 68px; - margin: 0 auto; - padding: 13px 20px; - - gap: 20px; - `} - - ${media.medium` - width: 100%; - height: 68px; - margin: 0; - padding: 13px 20px; - - gap: 10px; - `} - - ${media.small` - flex-direction: column; - align-items: center; - height: auto; - width: 100%; - padding: 0px; - gap: 0px; - - `} - -`; - - -//유저 정보 컨테이너 TO. Ashley Kim -export const RollingHeaderUserInfo = styled.div` - display: flex; - align-items: center; - min-width: 227px; - height: 42px; - line-height: 42px; - letter-spacing: -1%; - ${font.bold28} - color: ${colors.gray[800]}; - flex-shrink: 0; - - ${media.medium` - min-width: 150px; - height: 42px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - `} - - ${media.small` - width: 100%; - min-width: auto; - height: auto; - padding: 12px 20px; - border-bottom: 1px solid ${colors.gray[200]}; - `} -`; - - -export const RollingHeaderRightContainer = styled.div` - display: flex; - align-items: center; - gap: 20px; - flex-shrink: 1; - min-width: 0; - - ${media.medium` - gap: 8px; - flex-shrink: 1; - min-width: 0; - - `} - - ${media.small` - width: 100%; - padding: 8px 20px; - - - `} - - -`; - - - -//유저 이미지 컨테이너 프로필 사진들과, 몇명이 작성중인지 표시 -export const RollingHeaderUserPeopleContainer = styled.div` - width: 228px; - display: flex; - align-items: center; - - ${media.medium` - display: none; - `} - - ${media.small` - display: none; - `} -`; - -//유저 이미지 프로필 사진들 -export const RollingHeaderUserPeopleImages = styled.div` - display: flex; - width: 76px; - height: 28px; - position: relative; - - cursor: pointer; - ${media.medium` - - display: none; - `} - - ${media.small` - - display: none; - `} - -`; - -export const RollingHeaderUserPeopleImage = styled.img` - width: 28px; - height: 28px; - border-radius: 140px; - border: 1.4px solid #fff; - position: relative; - margin-left: -10px; -`; - -export const RollingHeaderUserDefaultImage = styled(RollingHeaderUserPeopleImage)``; - -//몇명이 작성중인지 -export const RollingHeaderUserPeopleState = styled.div` - width: 160px; - height: 27px; - line-height: 27px; - ${font.bold18} - color: ${colors.gray[900]}; - text-align: center; - ${media.medium` - - display: none; - `} - - ${media.small` - display: none; - `} -`; - -//이모지 컨테이너 드롭박스 포함, 추가 버튼 포함 -export const RollingHeaderImojiContainer = styled.div` - display: flex; - align-items: center; - gap: 8px; - -`; - -export const RollingHeaderImojiIconContainer = styled.div` - display: flex; - align-items: center; - justify-content: center; - width: auto; - height: auto; - padding: 8px 12px; - text-align: center; - border-radius: 32px; - background: rgba(153, 153, 153, 1); - gap: 2px; - - ${media.small` - padding: 4px 8px; - `} - -`; - -export const RollingHeaderImojiIcon = styled.div` - - width: 24px; - height: 24px; - color: rgba(255, 255, 255, 1); - - ${media.small` - width: 20px; - height: 24px; - `} - -`; - -export const RollingHeaderImojiText = styled.span` - ${font.regular16} - color: rgba(255, 255, 255, 1) -`; - -export const RollingHeaderImojiEditButton = styled.button` - display: flex; - align-items: center; - justify-content: center; - gap: 7px; - width: 88px; - height: 36px; - border-radius: 6px; - background: #fff; - border: 1px solid ${colors.gray[300]}; - cursor: pointer; - ${media.small` - width: 36px; - height: 32px; - `} -`; - -export const RollingHeaderImojiEditButtonContainer = styled.div` - display: flex; - align-items: center; - gap: 15px; -`; - -export const RollingHeaderImojiEditButtonIcon = styled.img` - width: 20px; - height: 20px; -`; - -export const RollingHeaderImojiEditButtonText = styled.span` - ${font.regular16} - color: ${colors.gray[900]}; - ${media.small` - display: none; - `} -`; - -export const RollingHeaderLinkShareButton = styled.div` - width: 56px; - height: 36px; - border-radius: 6px; - background-image: url("${ShareIcon}"); - background-size: 24px 24px; - background-repeat: no-repeat; - background-position: center; - padding: 12px 32px; - cursor: pointer; - border: 1px solid ${colors.gray[300]}; - ${media.small` - width: 36px; - height: 32px; - background-size: 20px 20px; - padding: 8px 8px; - - `} - -`; - -export const RollingHeaderArrowDown = styled.img` - width: 24px; - height: 24px; - cursor: pointer; -`; - - - - - - - -export const PerpendicularLine = styled.div` - border-left: 1px solid ${colors.gray[200]}; - height: 28px; -`; - -export const PerpendicularLineFirst = styled(PerpendicularLine)` - ${media.medium` - display: none; - `} - - ${media.small` - display: none; - `} -`; - -export const PerpendicularLineSecond = styled(PerpendicularLine)``; - - - -export const RollingPageContainer = styled.div` -display: flex; -justify-content: center; -align-items: center; -background-color: ${colors.blue[100]}; -width: 100%; -margin: 0 auto; -padding: 20px; - -`; - - - -export const CardContainer = styled.div` - display: grid; - grid-template-columns: repeat(3, 1fr); - grid-template-rows: repeat(2, 1fr); - - gap: 20px; - ${media.medium` - grid-template-columns: repeat(2, 1fr); - grid-template-rows: repeat(3, 1fr); - `} - ${media.small` - grid-template-columns: repeat(1, 1fr); - grid-template-rows: repeat(6, 1fr); - `} -`; - -export const Card = styled.div` - display: flex; - justify-content: center; - align-items: center; - width: 384px; - height: 280px; - border-radius: 16px; - background-color: #fff; - position: relative; -`; - -export const CardEditButton = styled.button` - width: 56px; - height: 56px; - background-image: url("${EditIcon}"); - background-color: ${colors.gray[500]}; - border-radius: 100px; - border: none; - padding: 20px; - background-size: 24px 24px; - background-repeat: no-repeat; - background-position: center; - cursor: pointer; - &:hover { - background-color: ${colors.gray[400]}; - color: ${colors.gray[100]}; - } -`; - -export const CardContentContainer = styled.div` - display: flex; - flex-direction: column; - justify-content: center; - width: 100%; - height: 100%; - padding: 16px 24px; -`; - -export const CardContentStatus = styled.div` - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - height: auto; - gap: 14px; - border-bottom: 1px solid ${colors.gray[200]}; - padding-bottom: 16px; -`; - -export const CardContentStatusContainer = styled.div` - display: flex; - align-items: center; - gap: 14px; -`; - -export const CardContentStatusProfileContainer = styled.div` - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 6px; -`; - -export const CardContentFrom = styled.div` - - display: flex; - justify-content: center; - align-items: center; - width: 100%; - height: 100%; -`; - -export const CardContentStatusProfileImage = styled.img` - width: 56px; - height: 56px; - border-radius: 100px; - border: 1px solid ${colors.gray[300]}; -`; - -export const CardContentStatusProfileName = styled.div` - ${font.regular16} - color: ${colors.gray[900]}; -`; - -const relationshipColors = { - friend: colors.blue[100], - family: colors.green[100], - colleague: colors.purple[100], - acquaintance: colors.beige[100], -}; - -const relationshipTextColors = { - friend: colors.blue[500], - family: colors.green[500], - colleague: colors.purple[600], - acquaintance: colors.beige[500], -}; - -// const relationshipLabels = { -// friend: '친구', -// family: '가족', -// colleague: '동료', -// acquaintance: '지인', -// }; - -export const CardContentStatusRelationship = styled.div` - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0 8px; - height: 20px; - border-radius: 4px; - ${font.regular14} - color: ${props => relationshipTextColors[props.$relationship] || colors.gray[500]}; - - background-color: ${props => relationshipColors[props.$relationship] || colors.gray[500]}; -`; - -export const CardContentText = styled.div` - width: 100%; - height: 100%; - ${font.regular16} - color: ${colors.gray[600]}; - padding-top: 16px; - cursor: pointer; -`; - -export const CardContentDate = styled.div` - ${font.regular12} - color: ${colors.gray[400]}; -`; - -export const CardContentDeleteButton = styled.div` - width: 40px; - height: 40px; - background-image: url("${DeleteIcon}"); - border-radius: 6px; - border: 1px solid ${colors.gray[300]}; - padding: 20px; - background-size: 24px 24px; - background-repeat: no-repeat; - background-position: center; - cursor: pointer; - &:hover { - background-color: ${colors.gray[200]}; - color: ${colors.gray[100]}; - } -`; \ No newline at end of file +import React from "react"; + +import styled from "styled-components"; +import { colors } from "@/styles/colors"; +import { font } from "@/styles/font"; +import ShareIcon from "@/assets/icons/share.svg"; +import media from "@/styles/media"; +import EditIcon from "@/assets/icons/plus.svg"; +import DeleteIcon from "@/assets/icons/deleted.svg"; + +//최상단헤더 컨테이너 +export const RollingHeaderContainer = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + width: 1200px; + margin: 0 auto; + height: 68px; + background-color: rgba(255, 255, 255, 1); + gap: 20px; + + ${media.large` + width: 1200px; + height: 68px; + margin: 0 auto; + padding: 13px 0px; + + gap: 20px; + `} + + ${media.medium` + width: 100%; + height: 68px; + margin: 0; + padding: 13px 20px; + + gap: 10px; + `} + + ${media.small` + flex-direction: column; + align-items: center; + + height: auto; + width: 100%; + padding: 0px; + gap: 0px; + + `} +`; + +//유저 정보 컨테이너 TO. Ashley Kim +export const RollingHeaderUserInfo = styled.div` + display: flex; + align-items: center; + min-width: 227px; + height: 42px; + line-height: 42px; + letter-spacing: -1%; + ${font.bold28} + color: ${colors.gray[800]}; + flex-shrink: 0; + + ${media.medium` + min-width: 150px; + height: 42px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + `} + + ${media.small` + width: 100%; + min-width: auto; + height: auto; + padding: 12px 20px; + border-bottom: 1px solid ${colors.gray[200]}; + `} +`; + +export const RollingHeaderRightContainer = styled.div` + display: flex; + align-items: center; + gap: 20px; + flex-shrink: 1; + min-width: 0; + + ${media.medium` + gap: 8px; + flex-shrink: 1; + min-width: 0; + + `} + + ${media.small` + width: 100%; + padding: 8px 20px; + + + `} +`; + +//유저 이미지 컨테이너 프로필 사진들과, 몇명이 작성중인지 표시 +export const RollingHeaderUserPeopleContainer = styled.div` + width: 228px; + display: flex; + align-items: center; + + ${media.medium` + display: none; + `} + + ${media.small` + display: none; + `} +`; + +//유저 이미지 프로필 사진들 +export const RollingHeaderUserPeopleImages = styled.div` + display: flex; + width: 76px; + height: 28px; + position: relative; + + cursor: pointer; + ${media.medium` + + display: none; + `} + + ${media.small` + + display: none; + `} +`; + +export const RollingHeaderUserPeopleImage = styled.img` + width: 28px; + height: 28px; + border-radius: 140px; + border: 1.4px solid #fff; + position: relative; + margin-left: -10px; +`; + +export const RollingHeaderUserDefaultImage = styled(RollingHeaderUserPeopleImage)``; + +//몇명이 작성중인지 +export const RollingHeaderUserPeopleState = styled.div` + width: 160px; + height: 27px; + line-height: 27px; + ${font.bold18} + color: ${colors.gray[900]}; + text-align: center; + ${media.medium` + + display: none; + `} + + ${media.small` + display: none; + `} +`; + +//이모지 컨테이너 드롭박스 포함, 추가 버튼 포함 +export const RollingHeaderEmojiContainer = styled.div` + display: flex; + align-items: center; + gap: 8px; +`; + +export const RollingHeaderEmojiIconContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: auto; + height: auto; + padding: 8px 12px; + text-align: center; + border-radius: 32px; + background: rgba(153, 153, 153, 1); + gap: 6px; + + ${media.small` + padding: 6px 10px; + `} +`; + +export const RollingHeaderEmojiIcon = styled.div` + width: 24px; + height: 24px; + color: rgba(255, 255, 255, 1); + + ${media.small` + width: 20px; + height: 24px; + + `} +`; + +export const RollingHeaderEmojiText = styled.span` + ${font.regular16} + color: rgba(255, 255, 255, 1) +`; + +export const RollingHeaderEmojiEditButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + width: 88px; + height: 36px; + border-radius: 6px; + background: #fff; + border: 1px solid ${colors.gray[300]}; + cursor: pointer; + ${media.small` + width: 36px; + height: 32px; + `} + &:hover { + background-color: ${colors.gray[100]}; + } +`; + +export const RollingHeaderEmojiEditButtonContainer = styled.div` + display: flex; + align-items: center; + gap: 15px; +`; + +export const RollingHeaderEmojiEditButtonIcon = styled.img` + width: 20px; + height: 20px; +`; + +export const RollingHeaderEmojiEditButtonText = styled.span` + ${font.regular16} + color: ${colors.gray[900]}; + ${media.small` + display: none; + `} +`; + +export const RollingHeaderLinkShareButton = styled.div` + width: 56px; + height: 36px; + border-radius: 6px; + background-image: url("${ShareIcon}"); + background-size: 24px 24px; + background-repeat: no-repeat; + background-position: center; + padding: 12px 32px; + cursor: pointer; + border: 1px solid ${colors.gray[300]}; + &:hover { + background-color: ${colors.gray[100]}; + } + ${media.small` + width: 36px; + height: 32px; + background-size: 20px 20px; + padding: 8px 8px; + + `} +`; + +export const RollingHeaderArrowDown = styled.img` + width: 24px; + height: 24px; + cursor: pointer; +`; + +export const PerpendicularLine = styled.div` + border-left: 1px solid ${colors.gray[200]}; + height: 28px; +`; + +export const PerpendicularLineFirst = styled(PerpendicularLine)` + ${media.medium` + display: none; + `} + + ${media.small` + display: none; + `} +`; + +export const PerpendicularLineSecond = styled(PerpendicularLine)``; + + +const RollingPageWrapper = ({ $backgroundcolor, $backgroundimage, ...rest }) => { + return React.createElement('div', { $backgroundcolor, $backgroundimage, ...rest }); +}; +export const RollingPageContainer = styled(RollingPageWrapper)` + display: flex; + justify-content: center; + background-color: ${(props) => props.$backgroundcolor || colors.blue[100]}; + background-image: ${(props) => + props.$backgroundimage ? `url(${props.$backgroundimage})` : "none"}; + background-size: cover; + background-repeat: no-repeat; + width: 100%; + min-height: 100vh; + margin: 0 auto; + padding: 63px 216px 113px 216px; + gap: 11px; + + + ${media.medium` + padding: 5% 2%; + `} + + ${media.small` + padding: 63px 20px 113px 20px; + `} +`; + + + +export const CardContainerWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 11px; + + & > :first-child { + align-self: flex-end; + } + + ${media.medium` + gap: 7px; + width: 100%; + `} + + ${media.small` + gap: 4px; + `} +`; + +export const CardContainer = styled.div` + display: grid; + width: 100%; + height: 100%; + grid-template-columns: repeat(3, 1fr); + grid-template-rows: repeat(2, 1fr); + + gap: 20px; + ${media.medium` + grid-template-columns: repeat(2, 1fr); + grid-template-rows: repeat(3, 1fr); + gap: 16px; + `} + ${media.small` + grid-template-columns: repeat(1, 1fr); + grid-template-rows: repeat(6, 1fr); + gap: 16px; + + `} +`; + +export const Card = styled.div` + display: flex; + justify-content: center; + align-items: center; + width: 384px; + height: 280px; + border-radius: 16px; + background-color: #fff; + position: relative; + + ${media.medium` + width: 100%; + height: auto; + `} + + ${media.small` + width: 100%; + `} +`; + +export const CardEditButton = styled.button` + width: 56px; + height: 56px; + background-image: url("${EditIcon}"); + background-color: ${colors.gray[500]}; + border-radius: 100px; + border: none; + padding: 20px; + background-size: 24px 24px; + background-repeat: no-repeat; + background-position: center; + cursor: pointer; + &:hover { + background-color: ${colors.gray[400]}; + color: ${colors.gray[100]}; + } +`; + +export const CardContentContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + width: 100%; + height: 100%; + gap: 15px; + padding: 28px 24px; +`; + +export const CardContentStatus = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + height: auto; + gap: 14px; + border-bottom: 1px solid ${colors.gray[200]}; + padding-bottom: 16px; +`; + +export const CardContentStatusContainer = styled.div` + display: flex; + align-items: center; + gap: 14px; +`; + +export const CardContentStatusProfileContainer = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; +`; + +export const CardContentFrom = styled.div` + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; +`; + +export const CardContentStatusProfileImage = styled.img` + width: 56px; + height: 56px; + border-radius: 100px; + border: 1px solid ${colors.gray[300]}; +`; + +export const CardContentStatusProfileName = styled.div` + ${font.regular16} + color: ${colors.gray[900]}; +`; + +const relationshipColors = { + friend: colors.blue[100], + family: colors.green[100], + colleague: colors.purple[100], + acquaintance: colors.beige[100], +}; + +const relationshipTextColors = { + friend: colors.blue[500], + family: colors.green[500], + colleague: colors.purple[600], + acquaintance: colors.beige[500], +}; + + + +export const CardContentStatusRelationship = styled.div` + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 8px; + height: 20px; + border-radius: 4px; + ${font.regular14} + color: ${(props) => relationshipTextColors[props.$relationship] || colors.gray[500]}; + + background-color: ${(props) => relationshipColors[props.$relationship] || colors.gray[500]}; +`; + +export const CardContentText = styled.div` + width: 100%; + height: 40%; + ${font.regular18} + color: ${colors.gray[600]}; + cursor: pointer; + line-height: 28px; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + +`; + +export const CardContentDate = styled.div` + ${font.regular12} + color: ${colors.gray[400]}; +`; + +export const CardContentDeleteButton = styled.div` + width: 40px; + height: 40px; + background-image: url("${DeleteIcon}"); + border-radius: 6px; + border: 1px solid ${colors.gray[300]}; + padding: 20px; + background-size: 24px 24px; + background-repeat: no-repeat; + background-position: center; + cursor: pointer; + &:hover { + background-color: ${colors.gray[200]}; + color: ${colors.gray[100]}; + } +`; + +export const CardPageDeleteButton = styled.div` + display: flex; + justify-content: center; + align-items: center; + width: 92px; + height: 39px; + border-radius: 6px; + border: 1px solid ${colors.gray[300]}; + background-color: ${colors.purple[600]}; + cursor: pointer; + color: #fff; + padding: 7px 16px; + ${font.regular16} + text-align: center; + + ${media.medium` + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + width: calc(100% - 40px); + height: 56px; + padding: 12px 16px; + z-index: 1003; + box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.2); + `} + + ${media.small` + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + width: calc(100% - 40px); + height: 56px; + padding: 12px 16px; + z-index: 1003; + box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.2); + `} +`;