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 && (