diff --git a/package-lock.json b/package-lock.json
index c1244cd..82e0efa 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,9 +10,11 @@
"dependencies": {
"axios": "^1.13.2",
"emoji-picker-react": "^4.15.0",
+ "quill": "^2.0.3",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-infinite-scroll-component": "^6.1.0",
+ "react-quill-new": "^3.6.0",
"react-router": "^7.9.5",
"styled-components": "^6.1.19",
"swiper": "^12.0.3"
@@ -2119,6 +2121,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/eventemitter3": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
+ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
+ "license": "MIT"
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2126,6 +2134,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/fast-diff": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
+ "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
+ "license": "Apache-2.0"
+ },
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -2484,9 +2498,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
- "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
+ "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2583,6 +2597,25 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/lodash-es": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
+ "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.clonedeep": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
+ "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isequal": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
+ "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
+ "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
+ "license": "MIT"
+ },
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -2732,6 +2765,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/parchment": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
+ "integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -2845,6 +2884,35 @@
"node": ">=6"
}
},
+ "node_modules/quill": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
+ "integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "eventemitter3": "^5.0.1",
+ "lodash-es": "^4.17.21",
+ "parchment": "^3.0.0",
+ "quill-delta": "^5.1.0"
+ },
+ "engines": {
+ "npm": ">=8.2.3"
+ }
+ },
+ "node_modules/quill-delta": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
+ "integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-diff": "^1.3.0",
+ "lodash.clonedeep": "^4.5.0",
+ "lodash.isequal": "^4.5.0"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ }
+ },
"node_modules/react": {
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
@@ -2878,6 +2946,21 @@
"react": ">=16.0.0"
}
},
+ "node_modules/react-quill-new": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/react-quill-new/-/react-quill-new-3.6.0.tgz",
+ "integrity": "sha512-weU6YfB2+7Cujw5Hjgmi0aN/qJd3B6ADWrxgUJMp2MO3tEvKX5kfB0sg3P0UdOVfU0z8icsKFzlnEIpeW1mLhw==",
+ "license": "MIT",
+ "dependencies": {
+ "lodash-es": "^4.17.21",
+ "quill": "~2.0.2"
+ },
+ "peerDependencies": {
+ "quill-delta": "^5.1.0",
+ "react": "^16 || ^17 || ^18 || ^19",
+ "react-dom": "^16 || ^17 || ^18 || ^19"
+ }
+ },
"node_modules/react-refresh": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
diff --git a/package.json b/package.json
index 8b2cbc5..e8e3e76 100644
--- a/package.json
+++ b/package.json
@@ -11,9 +11,11 @@
},
"dependencies": {
"axios": "^1.13.2",
+ "quill": "^2.0.3",
"emoji-picker-react": "^4.15.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
+ "react-quill-new": "^3.6.0",
"react-infinite-scroll-component": "^6.1.0",
"react-router": "^7.9.5",
"styled-components": "^6.1.19",
diff --git a/src/components/common/header.jsx b/src/components/common/header.jsx
index 75c43fa..88c9d00 100644
--- a/src/components/common/header.jsx
+++ b/src/components/common/header.jsx
@@ -11,7 +11,9 @@ const ContainWrapper = styled.div`
border-bottom: 1px solid #ededed;
z-index: 1003;
- ${props => props.$isRollingPage && media.small`
+ ${(props) =>
+ props.$isRollingPage &&
+ media.small`
display: none;
`}
`;
@@ -45,7 +47,7 @@ const ButtonWrapper = styled.div`
export default function Header({ showButton }) {
const location = useLocation();
- const isRollingPage = location.pathname.startsWith('/post/');
+ const isRollingPage = location.pathname.startsWith("/post/");
return (
<>
diff --git a/src/components/common/modal-layout.jsx b/src/components/common/modal-layout.jsx
index e6b032d..ba9ba32 100644
--- a/src/components/common/modal-layout.jsx
+++ b/src/components/common/modal-layout.jsx
@@ -69,7 +69,13 @@ const CloseButton = styled.button`
* 공통 모달 레이아웃 컴포넌트
* 책임: 모달의 기본 구조와 레이아웃 제공
*/
-export default function ModalLayout({ isOpen, onClose, title, children, showCloseButton = true }) {
+export default function ModalLayout({
+ isOpen,
+ onClose,
+ title,
+ children,
+ showCloseButton = true,
+}) {
if (!isOpen) return null;
return (
diff --git a/src/components/message/drop-down.jsx b/src/components/message/drop-down.jsx
new file mode 100644
index 0000000..e2302e2
--- /dev/null
+++ b/src/components/message/drop-down.jsx
@@ -0,0 +1,159 @@
+import React, { useState, useRef, useEffect, useCallback } from "react";
+import styled, { css } from "styled-components";
+import { colors } from "@/styles/colors";
+import { font } from "@/styles/font";
+
+import ARROW_ICON from "@/assets/icons/arrow-right.svg";
+
+const DropDownWrapper = styled.div`
+ position: relative;
+ width: 320px;
+`;
+
+const DropDownTrigger = styled.button`
+ width: 100%;
+ height: 50px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+
+ padding: 12px 16px;
+ border-radius: 8px;
+ border: 1px solid ${colors.gray[300]};
+ background-color: #fff;
+
+ ${font.regular16}
+ text-align: left;
+
+ outline: none;
+
+ color: ${({ $currentValue, defaultValue, $isInitialLoad }) => {
+ if ($isInitialLoad && $currentValue === defaultValue) {
+ return colors.gray[500];
+ }
+ return colors.gray[900];
+ }};
+
+ ${({ $isOpen }) =>
+ $isOpen &&
+ css`
+ border: 2px solid ${colors.gray[500]};
+ padding: 11px 15px;
+ `}
+`;
+
+const ArrowImage = styled.img`
+ width: 16px;
+ height: 16px;
+ transform: rotate(${({ $isOpen }) => ($isOpen ? "270deg" : "90deg")});
+ transition: transform 0.2s;
+`;
+
+const DropDownMenuContainer = styled.ul`
+ list-style: none;
+ margin: 10px 1px;
+ padding: 1px;
+
+ position: absolute;
+ top: 100%;
+ left: 0;
+ z-index: 10;
+ width: 320px;
+ max-height: 220px;
+ overflow-y: auto;
+
+ background-color: #ffffff;
+ border: 1px solid ${colors.gray[300]};
+ border-radius: 8px;
+
+ box-shadow: 0px 2px 12px 0px rgba(0, 0, 0, 0.08);
+`;
+
+const DropDownItem = styled.li`
+ height: 50px;
+ display: flex;
+ align-items: center;
+ padding: 12px 16px;
+
+ ${font.regular16}
+ color: ${colors.gray[900]};
+
+ &:hover {
+ background-color: ${colors.gray[100]};
+ }
+`;
+
+function DropDown({ id, name, defaultValue, value, onChange, options }) {
+ const [isOpen, setIsOpen] = useState(false);
+ const dropdownRef = useRef(null);
+ const currentValue = value;
+ const [isInitialLoad, setIsInitialLoad] = useState(true);
+
+ const handleClickOutside = useCallback((event) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
+ setIsOpen(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ };
+ }, [handleClickOutside]);
+
+ const handleItemClick = (optionValue) => {
+ onChange({ target: { name: name, value: optionValue } });
+ setIsOpen(false);
+ setIsInitialLoad(false);
+ };
+
+ const handleTriggerClick = () => {
+ setIsOpen((prev) => !prev);
+ if (isInitialLoad) {
+ setIsInitialLoad(false);
+ }
+ };
+
+ const selectedOption = options.find((opt) => opt.value === currentValue) || {
+ label: defaultValue,
+ value: defaultValue,
+ };
+
+ return (
+
+
+ {selectedOption.label}
+
+
+
+ {isOpen && (
+
+ {options.map((option) => (
+ handleItemClick(option.value)}
+ >
+ {option.label}
+
+ ))}
+
+ )}
+
+ );
+}
+
+export default DropDown;
diff --git a/src/components/message/from-input.jsx b/src/components/message/from-input.jsx
new file mode 100644
index 0000000..16a1e58
--- /dev/null
+++ b/src/components/message/from-input.jsx
@@ -0,0 +1,47 @@
+import React from "react";
+import styled, { css } from "styled-components";
+import { colors } from "@/styles/colors";
+import {
+ FormInputStyle,
+ ErrorMessage,
+ FormField,
+} from "@/pages/message-page.jsx";
+
+const ErrorOverrideStyle = css`
+ border-color: ${colors.error};
+
+ &:focus {
+ border-color: ${colors.error};
+ }
+`;
+const StyledInput = styled.input`
+ ${() => FormInputStyle}
+ ${(props) => props.$hasError && ErrorOverrideStyle}
+`;
+
+export default function FromInput({
+ id,
+ name,
+ placeholder,
+ hasError,
+ errorMessage,
+ onBlur,
+ onChange,
+ value,
+}) {
+ return (
+
+
+ {/* 에러 메시지 표시 */}
+ {hasError && {errorMessage}}
+
+ );
+}
diff --git a/src/components/message/profile-image-selector.jsx b/src/components/message/profile-image-selector.jsx
new file mode 100644
index 0000000..b387e85
--- /dev/null
+++ b/src/components/message/profile-image-selector.jsx
@@ -0,0 +1,148 @@
+import React from "react";
+import styled from "styled-components";
+import { colors } from "@/styles/colors";
+import { font } from "@/styles/font";
+import { DEFAULT_IMAGE_ID } from "@/hooks/use-profile-image";
+import defaultIcon from "@/assets/icons/person.svg";
+
+const DEFAULT_ICON_URL = defaultIcon;
+
+const FormLabel = styled.label`
+ ${font.bold24}
+ line-height: 36px;
+ letter-spacing: -0.01em;
+ color: ${colors.gray[900]};
+ margin: 0;
+ padding: 0;
+`;
+
+const ProfileWrapper = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+`;
+
+const SelectorPrompt = styled.p`
+ ${font.regular16};
+ color: ${colors.gray[500]};
+ margin: 0;
+ padding: 0;
+`;
+
+const ProfileSelectorContainer = styled.div`
+ display: flex;
+ flex-direction: row;
+ align-items: flex-start;
+ gap: 32px;
+ padding: 4px 0;
+`;
+
+const SelectorRightBlock = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+`;
+
+const ProfileDefaultBox = styled.div`
+ width: 80px;
+ height: 80px;
+ border-radius: 50%;
+ overflow: hidden;
+ flex-shrink: 0;
+ background-color: ${colors.gray[300]};
+
+ border: 1px solid
+ ${(props) => (props.$isSelected ? colors.purple[700] : colors.gray[200])};
+ ${(props) => props.$isSelected && `border-width: 2px;`}
+
+ cursor: pointer;
+ transition: border 0.2s;
+
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ img {
+ width: 32px;
+ height: 32px;
+ object-fit: contain;
+ border: none;
+ }
+`;
+
+const SelectableImagesList = styled.ul`
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0px;
+
+ list-style: none;
+ margin: 0;
+ padding: 0;
+`;
+
+const SelectableImageItem = styled.li`
+ width: 56px;
+ height: 56px;
+ border-radius: 50%;
+ overflow: hidden;
+ cursor: pointer;
+ flex-shrink: 0;
+ transition: border 0.2s;
+
+ border: 2px solid
+ ${(props) => (props.$isSelected ? colors.purple[700] : "transparent")};
+
+ &:hover {
+ opacity: 0.8;
+ }
+
+ img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ border: none;
+ }
+`;
+
+function ProfileImageSelector({
+ selectedId,
+ onImageSelect,
+ selectableImages,
+ isLoading,
+ error,
+}) {
+ return (
+
+ 프로필 이미지
+
+ onImageSelect(DEFAULT_IMAGE_ID)}
+ >
+
+
+
+
+ 프로필 이미지를 선택해주세요!
+
+ {isLoading && 이미지를 불러오는 중입니다...
}
+ {error && 이미지를 불러오는 데 실패했습니다.
}
+ {!isLoading &&
+ !error &&
+ selectableImages.map((image) => (
+ onImageSelect(image.id)}
+ >
+
+
+ ))}
+
+
+
+
+ );
+}
+
+export default ProfileImageSelector;
diff --git a/src/components/message/reach-text-editor.jsx b/src/components/message/reach-text-editor.jsx
new file mode 100644
index 0000000..e2be532
--- /dev/null
+++ b/src/components/message/reach-text-editor.jsx
@@ -0,0 +1,92 @@
+import React from "react";
+import ReactQuill from "react-quill-new";
+import "react-quill-new/dist/quill.snow.css";
+
+import styled from "styled-components";
+import { font } from "@/styles/font";
+import { colors } from "@/styles/colors";
+import Quill from "quill";
+
+const list = Quill.import("formats/list");
+
+if (list) {
+ Quill.register(list, true);
+}
+
+const EditorContainer = styled.div`
+ min-height: 243px;
+ border-radius: 8px;
+ border: 1px solid #ccc;
+ overflow: hidden;
+
+ .ql-toolbar.ql-snow {
+ background-color: #eee;
+ border: none;
+ border-bottom: 1px solid #ccc;
+ padding: 14px 16px;
+ line-height: 1;
+
+ .ql-formats {
+ margin-right: 12px;
+ }
+ .ql-formats button,
+ .ql-formats select {
+ width: 24px;
+ height: 24px;
+ padding: 0;
+ margin-right: 8px;
+ }
+ }
+
+ .ql-container.ql-snow {
+ border: none;
+ ${font.regular16};
+ color: ${colors.gray[900]};
+ }
+
+ .ql-editor {
+ min-height: 200px;
+ padding: 16px;
+ }
+`;
+
+function RichTextEditor({ value, onChange }) {
+ const modules = {
+ toolbar: [
+ ["bold", "italic", "underline"],
+ [
+ { align: "" },
+ { align: "center" },
+ { align: "right" },
+ { align: "justify" },
+ ],
+ [{ list: "ordered" }, { list: "bullet" }],
+ ["link", "image"],
+ ],
+ };
+
+ const formats = [
+ "bold",
+ "italic",
+ "underline",
+ "align",
+ "list",
+ "bullet",
+ "link",
+ "image",
+ ];
+
+ return (
+
+
+
+ );
+}
+
+export default RichTextEditor;
diff --git a/src/components/rolling/card-contents.jsx b/src/components/rolling/card-contents.jsx
index c6c3361..c4b1bc6 100644
--- a/src/components/rolling/card-contents.jsx
+++ b/src/components/rolling/card-contents.jsx
@@ -2,19 +2,19 @@ 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,
+ 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";
@@ -28,160 +28,163 @@ import DeleteConfirmModal from "./delete-confirm-modal";
* @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 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 (
+ <>
+
+ 모든 메시지를 확인했습니다
+
}
- };
-
- 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}님의 메시지를 삭제하시겠습니까?`
- }
- />
- >
- );
+ >
+
+ {/* 뷰어 모드일 때만 카드 추가 버튼 표시 */}
+ {!isEditMode && (
+
+ handleCardEditClick(recipientId)}
+ />
+
+ )}
+
+ {messages.map((message) => (
+ handleCardClick(message)}>
+
+
+
+
+
+
+ From. {message.sender}
+
+
+ {message.relationship}
+
+
+
+
+ {/* 편집 모드일 때만 카드 삭제 버튼 표시 */}
+ {isEditMode && (
+ {
+ e.stopPropagation(); // 카드 클릭 이벤트 방지
+ handleOpenDeleteModal(message);
+ }}
+ />
+ )}
+
+ {message.content}
+
+ {formatDate(message.createdAt)}
+
+
+
+ ))}
+
+
+
+ {/* 카드 상세 모달 */}
+
+
+ {/* 삭제 확인 모달 */}
+
+ >
+ );
}
diff --git a/src/components/rolling/emoji-display-list.jsx b/src/components/rolling/emoji-display-list.jsx
index 2aa45fe..9432b42 100644
--- a/src/components/rolling/emoji-display-list.jsx
+++ b/src/components/rolling/emoji-display-list.jsx
@@ -1,8 +1,8 @@
import React from "react";
import {
- RollingHeaderEmojiIconContainer,
- RollingHeaderEmojiText,
- RollingHeaderEmojiIcon,
+ RollingHeaderEmojiIconContainer,
+ RollingHeaderEmojiText,
+ RollingHeaderEmojiIcon,
} from "@/styles/rolling-page-styles";
/**
@@ -10,14 +10,14 @@ import {
* 책임: 상위 N개의 이모지를 화면에 표시
*/
export default function EmojiDisplayList({ emojis }) {
- return (
- <>
- {emojis.map((emojiData, index) => (
-
- {emojiData.emoji}
- {emojiData.count}
-
- ))}
- >
- );
+ 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 6b73e43..9afc24f 100644
--- a/src/components/rolling/emoji-dropdown.jsx
+++ b/src/components/rolling/emoji-dropdown.jsx
@@ -1,100 +1,106 @@
-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}
-
- ))}
-
-
- >
- )}
-
- );
-}
+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 ad9d4b3..aca5fab 100644
--- a/src/components/rolling/emoji-picker-component.jsx
+++ b/src/components/rolling/emoji-picker-component.jsx
@@ -32,7 +32,12 @@ const Overlay = styled.div`
* 이모지 선택기 컴포넌트
* 책임: 이모지 피커 UI 렌더링 및 이모지 선택 이벤트 처리
*/
-export default function EmojiPickerComponent({ isOpen, onClose, onEmojiSelect, children }) {
+export default function EmojiPickerComponent({
+ isOpen,
+ onClose,
+ onEmojiSelect,
+ children,
+}) {
const handleEmojiClick = (emojiData) => {
onEmojiSelect(emojiData.emoji);
onClose();
diff --git a/src/components/rolling/header-action-buttons.jsx b/src/components/rolling/header-action-buttons.jsx
index 49b5695..a5b8054 100644
--- a/src/components/rolling/header-action-buttons.jsx
+++ b/src/components/rolling/header-action-buttons.jsx
@@ -2,12 +2,12 @@ import React from "react";
import styled from "styled-components";
import EmojiPickerComponent from "./emoji-picker-component";
import {
- RollingHeaderEmojiEditButtonContainer,
- RollingHeaderEmojiEditButton,
- RollingHeaderEmojiEditButtonIcon,
- RollingHeaderEmojiEditButtonText,
- PerpendicularLineSecond,
- RollingHeaderLinkShareButton,
+ RollingHeaderEmojiEditButtonContainer,
+ RollingHeaderEmojiEditButton,
+ RollingHeaderEmojiEditButtonIcon,
+ RollingHeaderEmojiEditButtonText,
+ PerpendicularLineSecond,
+ RollingHeaderLinkShareButton,
} from "@/styles/rolling-page-styles";
const ShareButtonWrapper = styled.div`
@@ -19,36 +19,38 @@ const ShareButtonWrapper = styled.div`
* 책임: 이모지 추가 버튼과 공유 버튼 렌더링
*/
export default function HeaderActionButtons({
- isEmojiPickerOpen,
- onToggleEmojiPicker,
- onCloseEmojiPicker,
- onEmojiSelect,
- onShareClick,
- addEmojiIcon,
- shareIcon,
- shareModalComponent, // ShareModal 컴포넌트를 props로 받음
+ isEmojiPickerOpen,
+ onToggleEmojiPicker,
+ onCloseEmojiPicker,
+ onEmojiSelect,
+ onShareClick,
+ addEmojiIcon,
+ shareIcon,
+ shareModalComponent, // ShareModal 컴포넌트를 props로 받음
}) {
- return (
-
-
-
-
- 추가
-
-
-
-
-
- {shareModalComponent}
-
-
- );
+ return (
+
+
+
+
+
+ 추가
+
+
+
+
+
+
+ {shareModalComponent}
+
+
+ );
}
diff --git a/src/components/rolling/participant-section.jsx b/src/components/rolling/participant-section.jsx
index 4dbb7b3..8ff5470 100644
--- a/src/components/rolling/participant-section.jsx
+++ b/src/components/rolling/participant-section.jsx
@@ -1,7 +1,7 @@
import React from "react";
import {
- RollingHeaderUserPeopleContainer,
- RollingHeaderUserPeopleImages,
+ RollingHeaderUserPeopleContainer,
+ RollingHeaderUserPeopleImages,
} from "@/styles/rolling-page-styles";
import ProfileImageList from "./profile-image-list";
import ProfileOverflowBadge from "./profile-overflow-badge";
@@ -15,22 +15,29 @@ import useProfileImages from "@/hooks/use-profile-images";
* @param {number} totalCount - 전체 참여자 수 (messageCount)
* @param {number} maxVisible - 최대 표시 개수
*/
-export default function ParticipantSection({ profiles, totalCount, maxVisible = 3 }) {
- const { overflowCount, hasOverflow } = useProfileImages(totalCount, profiles, maxVisible);
+export default function ParticipantSection({
+ profiles,
+ totalCount,
+ maxVisible = 3,
+}) {
+ const { overflowCount, hasOverflow } = useProfileImages(
+ totalCount,
+ profiles,
+ maxVisible
+ );
+ return (
+
+
+ {/* 보이는 프로필 이미지들 */}
+
- return (
-
-
- {/* 보이는 프로필 이미지들 */}
-
+ {/* 오버플로우 뱃지 (+N) */}
+ {hasOverflow && }
+
- {/* 오버플로우 뱃지 (+N) */}
- {hasOverflow && }
-
-
- {/* 참여자 통계 텍스트 - API의 messageCount 사용 */}
-
-
- );
+ {/* 참여자 통계 텍스트 - API의 messageCount 사용 */}
+
+
+ );
}
diff --git a/src/components/rolling/participant-stats.jsx b/src/components/rolling/participant-stats.jsx
index d6f0f7a..fc6e2a2 100644
--- a/src/components/rolling/participant-stats.jsx
+++ b/src/components/rolling/participant-stats.jsx
@@ -1,18 +1,22 @@
-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/hooks/use-cards.js b/src/hooks/use-cards.js
index f9885aa..39ec223 100644
--- a/src/hooks/use-cards.js
+++ b/src/hooks/use-cards.js
@@ -1,29 +1,29 @@
-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;
-}
+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-dropdown.js b/src/hooks/use-dropdown.js
new file mode 100644
index 0000000..c4ed4b7
--- /dev/null
+++ b/src/hooks/use-dropdown.js
@@ -0,0 +1,16 @@
+import { useState } from "react";
+
+const useDropdown = (initialValue) => {
+ const [value, setValue] = useState(initialValue);
+
+ const handleChange = (e) => {
+ setValue(e.target.value);
+ };
+
+ return {
+ value,
+ handleChange,
+ };
+};
+
+export default useDropdown;
diff --git a/src/hooks/use-emoji-manager.js b/src/hooks/use-emoji-manager.js
index 34553ae..2d12e4c 100644
--- a/src/hooks/use-emoji-manager.js
+++ b/src/hooks/use-emoji-manager.js
@@ -8,7 +8,9 @@ export default function useEmojiManager(initialEmojis = []) {
const [selectedEmojis, setSelectedEmojis] = useState(initialEmojis);
const handleEmojiSelect = (emoji) => {
- const existingEmojiIndex = selectedEmojis.findIndex((item) => item.emoji === emoji);
+ const existingEmojiIndex = selectedEmojis.findIndex(
+ (item) => item.emoji === emoji
+ );
if (existingEmojiIndex !== -1) {
// 이미 존재하는 이모지면 카운트 증가
diff --git a/src/hooks/use-from-input.js b/src/hooks/use-from-input.js
new file mode 100644
index 0000000..f1c5118
--- /dev/null
+++ b/src/hooks/use-from-input.js
@@ -0,0 +1,24 @@
+import { useState } from "react";
+
+export default function useFormInput(initialValue = "") {
+ const [value, setValue] = useState(initialValue);
+ const [isTouched, setIsTouched] = useState(false);
+
+ // 에러 상태 계산 빙법: 포커스 아웃되었고, 값이 비어있을 때 에러
+ const hasError = isTouched && value.trim() === "";
+
+ const handleChange = (e) => {
+ setValue(e.target.value);
+ };
+
+ const handleBlur = () => {
+ setIsTouched(true);
+ };
+
+ return {
+ value,
+ hasError,
+ handleChange,
+ handleBlur,
+ };
+}
diff --git a/src/hooks/use-kakao-sdk.js b/src/hooks/use-kakao-sdk.js
index 18d0104..b3b4a2e 100644
--- a/src/hooks/use-kakao-sdk.js
+++ b/src/hooks/use-kakao-sdk.js
@@ -19,7 +19,8 @@ export default function useKakaoSdk() {
// 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.integrity =
+ "sha384-tJkjbtDbvoxO+diRuDtwRO9JXR7pjWnfjfRn5ePUpl7e7RJCxKCwwnfqUAdXh53p";
script.crossOrigin = "anonymous";
script.async = true;
diff --git a/src/hooks/use-message-form.js b/src/hooks/use-message-form.js
new file mode 100644
index 0000000..64f6073
--- /dev/null
+++ b/src/hooks/use-message-form.js
@@ -0,0 +1,189 @@
+import { useState, useMemo, useEffect } from "react";
+import axios from "axios";
+
+// API 호스트 루트
+const BASE_URL = "https://rolling-api.vercel.app";
+
+const FONT_OPTIONS = [{ value: "Noto Sans", label: "Noto Sans" }];
+
+const RELATIONSHIP_OPTIONS = [
+ { value: "지인", label: "지인" },
+ { value: "친구", label: "친구" },
+ { value: "가족", label: "가족" },
+ { value: "동료", label: "동료" },
+];
+
+// 유효성 검사 훅 (이전과 동일)
+const useInput = (initialValue, validate) => {
+ const [value, setValue] = useState(initialValue);
+ const [isTouched, setIsTouched] = useState(false);
+
+ const isValid = validate(value);
+ const hasError = isTouched && !isValid;
+
+ const handleChange = (e) => {
+ setValue(e.target.value);
+ };
+
+ const handleBlur = () => {
+ setIsTouched(true);
+ };
+
+ return { value, isValid, hasError, handleChange, handleBlur };
+};
+
+// 메시지 폼 및 Axios API 로직
+
+export const useMessageForm = () => {
+ // 상태 정의
+
+ const fromInput = useInput("", (val) => val.trim().length > 0);
+ const relationshipDropdown = useInput(
+ RELATIONSHIP_OPTIONS[0].value,
+ (val) => val.trim().length > 0
+ );
+ const fontDropdown = useInput(
+ FONT_OPTIONS[0].value,
+ (val) => val.trim().length > 0
+ );
+ const [editorContent, setEditorContent] = useState("");
+
+ const [selectedProfileImageId, setSelectedProfileImageId] = useState(0);
+ const [isSubmitting, setIsSubmitting] = useState(false); // POST 요청 상태
+
+ // 이미지 GET 요청 상태
+ const [selectableImages, setSelectableImages] = useState([]);
+ const [isImagesLoading, setIsImagesLoading] = useState(true);
+ const [imagesError, setImagesError] = useState(null);
+
+ const isEditorContentValid = editorContent.trim().length > 0;
+
+ const isFormValid = useMemo(
+ () =>
+ fromInput.isValid &&
+ relationshipDropdown.isValid &&
+ fontDropdown.isValid &&
+ isEditorContentValid,
+ [
+ fromInput.isValid,
+ relationshipDropdown.isValid,
+ fontDropdown.isValid,
+ isEditorContentValid,
+ ]
+ );
+
+ // [GET 요청] 프로필 이미지 목록 가져오기
+
+ useEffect(() => {
+ const PROFILE_API_URL = `${BASE_URL}/profile-images/`;
+
+ const fetchImages = async () => {
+ setIsImagesLoading(true);
+ setImagesError(null);
+
+ try {
+ console.log(`[API CALL] GET 요청 시작: ${PROFILE_API_URL}`);
+
+ // axios.get 사용
+ const response = await axios.get(PROFILE_API_URL);
+ const data = response.data;
+
+ // 응답 데이터 (imageUrls 배열)를 변환하여 상태에 저장
+ const images = data.imageUrls.map((url, index) => ({
+ id: index + 1,
+ url: url,
+ }));
+
+ setSelectableImages(images);
+ // 첫 번째 이미지를 기본값으로 선택
+ if (images.length > 0 && selectedProfileImageId === 0) {
+ setSelectedProfileImageId(images[0].id);
+ }
+ } catch (err) {
+ console.error("프로필 이미지 GET 요청 실패:", err);
+ // Axios 에러 객체에서 메시지 추출
+ const errorMessage = err.response?.data?.message || err.message;
+ setImagesError(new Error(errorMessage));
+ setSelectableImages([]);
+ } finally {
+ setIsImagesLoading(false);
+ }
+ };
+
+ fetchImages();
+ }, []);
+
+ // [POST 요청] 폼 데이터 전송 (Axios 사용)
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+
+ if (!isFormValid || isSubmitting) {
+ fromInput.handleBlur();
+ return false;
+ }
+
+ // 프로필 이미지가 선택되었는지 확인
+ if (selectedProfileImageId === 0) {
+ alert("프로필 이미지를 선택해 주세요.");
+ return false;
+ }
+
+ setIsSubmitting(true);
+
+ // 선택된 이미지의 URL을 찾습니다.
+ const selectedImage = selectableImages.find(
+ (img) => img.id === selectedProfileImageId
+ );
+
+ const formData = {
+ // API 요구사항에 맞춰 필드 이름을 구성합니다.
+ sender: fromInput.value,
+ relationship: relationshipDropdown.value,
+ font: fontDropdown.value,
+ content: editorContent,
+ profileImageURL: selectedImage?.url || null,
+ createdAt: new Date().toISOString(),
+ };
+
+ // 롤링페이퍼 ID가 필요하다면 이 훅을 사용하는 컴포넌트에서 recipientId를 주입해야 합니다.
+ const POST_API_URL = `${BASE_URL}/messages/`;
+
+ try {
+ console.log(`[API CALL] POST 요청 시작: ${POST_API_URL}`);
+
+ // axios.post 사용
+ const response = await axios.post(POST_API_URL, formData);
+
+ console.log("롤링페이퍼 생성 성공. 서버 응답:", response.data);
+ return true;
+ } catch (error) {
+ console.error("롤링페이퍼 생성 중 API 오류 발생:", error);
+ // Axios 에러 객체에서 메시지 추출
+ const errorMessage = error.response?.data?.message || error.message;
+ alert(`롤링페이퍼 생성에 실패했습니다: ${errorMessage}`);
+ return false;
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ // 반환 값
+ return {
+ fromInput,
+ relationshipDropdown,
+ fontDropdown,
+ editorContent,
+ setEditorContent,
+ isFormValid,
+ handleSubmit,
+ RELATIONSHIP_OPTIONS,
+ FONT_OPTIONS,
+ selectedProfileImageId,
+ handleImageSelect: setSelectedProfileImageId,
+ selectableImages,
+ // 이미지 로딩 중이거나 제출 로딩 중이라면 true
+ isLoading: isImagesLoading || isSubmitting,
+ error: imagesError,
+ };
+};
diff --git a/src/hooks/use-profile-image.js b/src/hooks/use-profile-image.js
new file mode 100644
index 0000000..dcc8917
--- /dev/null
+++ b/src/hooks/use-profile-image.js
@@ -0,0 +1,59 @@
+import { useState, useEffect } from "react";
+
+import img1 from "@/assets/images/profile-img-01.webp";
+import img2 from "@/assets/images/profile-img-02.webp";
+
+export const DEFAULT_IMAGE_ID = 0;
+
+/* 프로필 이미지 선택 상태와 로직 및 API 통신을 관리하는 커스텀 훅 */
+export const useProfileImage = () => {
+ const [selectedProfileImageId, setSelectedProfileImageId] =
+ useState(DEFAULT_IMAGE_ID);
+
+ const [selectableImages, setSelectableImages] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const handleImageSelect = (id) => {
+ setSelectedProfileImageId(id);
+ };
+
+ useEffect(() => {
+ const API_ENDPOINT = "/api/profile-images";
+
+ async function fetchImages() {
+ try {
+ const MOCK_IMAGES = [
+ { id: 1, url: img2 },
+ { id: 2, url: img1 },
+ { id: 3, url: img2 },
+ { id: 4, url: img1 },
+ { id: 5, url: img2 },
+ { id: 6, url: img1 },
+ { id: 7, url: img2 },
+ { id: 8, url: img1 },
+ { id: 9, url: img2 },
+ { id: 10, url: img1 },
+ ];
+
+ setSelectableImages(MOCK_IMAGES);
+ setError(null);
+ } catch (err) {
+ setError(err.message);
+ console.error("Failed to fetch profile images:", err);
+ } finally {
+ setIsLoading(false);
+ }
+ }
+ fetchImages();
+ }, []);
+
+ return {
+ selectedProfileImageId,
+ handleImageSelect,
+ selectableImages,
+ DEFAULT_IMAGE_ID,
+ isLoading,
+ error,
+ };
+};
diff --git a/src/hooks/use-profile-images.js b/src/hooks/use-profile-images.js
index 34f011d..2ea9c10 100644
--- a/src/hooks/use-profile-images.js
+++ b/src/hooks/use-profile-images.js
@@ -15,11 +15,10 @@ export default function useProfileImages(totalCount, profiles, maxVisible = 3) {
};
}
-
const hasOverflow = totalCount > maxVisible;
// 오버플로우가 있으면 마지막 자리는 +N 표시용으로 비움
- const visibleCount = 3
+ const visibleCount = 3;
const visibleProfiles = profiles.slice(0, visibleCount);
const overflowCount = hasOverflow ? totalCount - visibleCount : 0;
diff --git a/src/hooks/use-share-actions.js b/src/hooks/use-share-actions.js
index a175c2f..b9c6bfb 100644
--- a/src/hooks/use-share-actions.js
+++ b/src/hooks/use-share-actions.js
@@ -22,7 +22,7 @@ export default function useShareActions() {
return false;
}
},
- [showToast],
+ [showToast]
);
/**
@@ -46,7 +46,7 @@ export default function useShareActions() {
return false;
}
},
- [showToast],
+ [showToast]
);
return {
diff --git a/src/pages/message-page.jsx b/src/pages/message-page.jsx
index 066eced..09ed23a 100644
--- a/src/pages/message-page.jsx
+++ b/src/pages/message-page.jsx
@@ -1,29 +1,40 @@
import React from "react";
import styled, { css } from "styled-components";
+import { colors } from "@/styles/colors";
+import { font } from "@/styles/font";
+import Button from "@/components/common/button";
+import DropDown from "@/components/message/drop-down";
+import FromInput from "@/components/message/from-input";
+import { useMessageForm } from "@/hooks/use-message-form";
+import RichTextEditor from "@/components/message/reach-text-editor";
-const DEFAULT_ICON_URL = "/assets/default-user.svg";
-const TEMP_IMAGE_URL = "/assets/temp-profile.jpg";
+import ProfileImageSelector from "@/components/message/profile-image-selector";
-const selectableImages = [
- { id: 1, url: TEMP_IMAGE_URL, isSelected: true },
- { id: 2, url: TEMP_IMAGE_URL, isSelected: false },
- { id: 3, url: TEMP_IMAGE_URL, isSelected: false },
- { id: 4, url: TEMP_IMAGE_URL, isSelected: false },
- { id: 5, url: TEMP_IMAGE_URL, isSelected: false },
- { id: 6, url: TEMP_IMAGE_URL, isSelected: false },
- { id: 7, url: TEMP_IMAGE_URL, isSelected: false },
-];
+export const FormInputStyle = css`
+ width: 100%;
+ padding: 12px 16px;
+ border-radius: 8px;
+ border: 1px solid ${colors.gray[300]};
+ ${font.regular16};
+ outline: none;
+ ${colors.gray[900]};
+ background-color: #fff;
+
+ &:focus {
+ border-color: ${colors.gray[500]};
+ }
+`;
export const PageContainer = styled.div`
max-width: 720px;
margin: 0 auto;
- padding: 60px 24px;
+ padding: 47px 24px 60px 24px;
`;
export const MessageFormBox = styled.form`
display: flex;
flex-direction: column;
- gap: 20px;
+ gap: 50px;
`;
export const FormField = styled.div`
@@ -33,206 +44,118 @@ export const FormField = styled.div`
`;
export const FormLabel = styled.label`
- font-size: 16px;
- font-weight: 700;
- line-height: 26px; /* 162.5% */
- color: #181818;
+ ${font.bold24}
+ line-height: 36px;
+ letter-spacing: -0.01em;
+ ${colors.gray[900]};
+ margin: 0;
+ padding: 0;
`;
export const InputField = styled.input`
- width: 100%;
- padding: 12px 16px;
- border-radius: 8px;
- border: 1px solid #ccc;
- font-size: 16px;
- outline: none;
+ ${FormInputStyle}
&:focus {
- border-color: #555;
+ border-color: ${colors.gray[500]};
}
`;
export const ErrorMessage = styled.p`
- color: #dc3545; /* 빨간색 계열 */
+ color: ${colors.error};
font-size: 14px;
- margin-top: -8px; /* 위쪽 갭 조정 */
-`;
-
-export const ProfileWrapper = styled.div`
- display: flex;
- flex-direction: column;
- gap: 12px;
-`;
-
-export const ProfileSelectorContainer = styled.div`
- display: flex;
- align-items: center;
- gap: 32px;
-`;
-
-export const ProfileDefaultBox = styled.div`
- width: 70px;
- height: 70px;
- border-radius: 50%;
- overflow: hidden;
- flex-shrink: 0; /* 크기 고정 */
- border: 1px solid #ccc;
-
- img {
- width: 100%;
- height: 100%;
- object-fit: cover;
- }
-`;
-
-export const SelectableImagesList = styled.ul`
- display: flex;
- gap: 4px;
- overflow-x: auto;
- padding: 4px 0; /* 스크롤바를 위한 패딩 */
- -webkit-overflow-scrolling: touch; /* iOS에서 부드러운 스크롤 */
- list-style: none;
- margin: 0;
-`;
-
-export const SelectableImageItem = styled.li`
- width: 56px;
- height: 56px;
- border-radius: 50%;
- overflow: hidden;
- cursor: pointer;
- flex-shrink: 0; /* 크기 고정 */
- transition:
- transform 0.2s,
- border 0.2s;
-
- border: 2px solid transparent;
- ${({ isSelected }) =>
- isSelected &&
- css`
- border-color: #3f60ff; /* 선택된 이미지 하이라이트 색상 */
- transform: scale(1.05);
- `}
-
- &:hover {
- opacity: 0.8;
- }
-
- img {
- width: 100%;
- height: 100%;
- object-fit: cover;
- }
-`;
-
-export const SelectField = styled.select`
- width: 100%;
- padding: 12px 16px;
- border-radius: 8px;
- border: 1px solid #ccc;
- font-size: 16px;
- background-color: #fff;
- appearance: none; /* 기본 드롭다운 화살표 숨기기 */
- outline: none;
-`;
-
-export const EditorPlaceholder = styled.div`
- min-height: 200px;
- padding: 16px;
- border-radius: 8px;
- border: 1px solid #ccc;
- background-color: #f9f9f9;
- font-size: 16px;
- color: #777;
+ margin-top: -8px;
`;
-export const SubmitButton = styled.button`
+const FullWidthButton = styled(Button)`
width: 100%;
- padding: 14px 0;
margin-top: 20px;
- border-radius: 12px;
- background-color: #3f60ff; /* Primary Color */
- color: #fff;
- font-size: 18px;
- font-weight: 700;
- border: none;
- cursor: pointer;
- transition: background-color 0.3s;
-
- &:disabled {
- background-color: #ccc;
- cursor: not-allowed;
- }
-
- &:not(:disabled):hover {
- background-color: #2e4bc0;
- }
`;
function MessagePage() {
- const isFormValid = false;
- const hasError = true;
+ const {
+ fromInput,
+ relationshipDropdown,
+ fontDropdown,
+ editorContent,
+ setEditorContent,
+ isFormValid,
+ handleSubmit,
+ RELATIONSHIP_OPTIONS,
+ FONT_OPTIONS,
+ selectedProfileImageId,
+ handleImageSelect,
+ selectableImages,
+ isLoading,
+ error,
+ } = useMessageForm();
return (
-
+
{/* From. 입력 필드 */}
From.
-
- {/* 에러 메시지 표시 */}
- {hasError && "값을 입력해 주세요."}
+
- {/* 프로필 이미지 선택창 */}
-
- 프로필 이미지
-
-
-
-
-
-
- {selectableImages.map((image) => (
-
-
-
- ))}
-
-
-
+
+ {/* 프로필 이미지 선택 */}
+
{/* 상대와의 관계 드롭다운*/}
상대와의 관계
-
-
-
-
-
-
+
- {/* 내용 입력 (Rich Text Editor 사용) */}
내용을 입력해 주세요
-
- I am your reach text editor.
-
+
{/* 폰트 선택 드롭다운 */}
폰트 선택
-
-
- {/* 추가 폰트 옵션 추가 예정 */}
-
+
{/* 생성하기 버튼 */}
-
+
생성하기
-
+
);
diff --git a/src/pages/rolling-page-edit.jsx b/src/pages/rolling-page-edit.jsx
new file mode 100644
index 0000000..8cea62b
--- /dev/null
+++ b/src/pages/rolling-page-edit.jsx
@@ -0,0 +1,58 @@
+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
new file mode 100644
index 0000000..f165398
--- /dev/null
+++ b/src/pages/rolling-page-head.jsx
@@ -0,0 +1,106 @@
+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 4661aba..8c8c9b4 100644
--- a/src/pages/rolling-page.jsx
+++ b/src/pages/rolling-page.jsx
@@ -42,7 +42,6 @@ export default function RollingPage() {
return 잘못된 페이지 주소입니다.
;
}
-
if (error) {
return 에러가 발생했습니다: {error}
;
}
@@ -52,11 +51,12 @@ export default function RollingPage() {
}
// recentMessages에서 프로필 데이터 추출 (최신순 3개)
- const profiles = recipient.recentMessages?.map((msg, index) => ({
- id: msg.id || index,
- name: msg.sender,
- profileImageURL: msg.profileImageURL,
- })) || [];
+ const profiles =
+ recipient.recentMessages?.map((msg, index) => ({
+ id: msg.id || index,
+ name: msg.sender,
+ profileImageURL: msg.profileImageURL,
+ })) || [];
// 페이지 삭제 핸들러
const handleOpenDeletePageModal = () => {
@@ -95,12 +95,12 @@ export default function RollingPage() {
ShareIcon={ShareIcon}
/>
-
+ $backgroundimage={recipient.backgroundImageURL}
+ >
{/* 편집 모드일 때만 페이지 삭제 버튼 표시 */}
{isEditMode && (