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);
+ `}
+`;