From 31b9b83340a06a36da850b927d9f98374e1efb67 Mon Sep 17 00:00:00 2001 From: yeseung00 Date: Wed, 12 Nov 2025 20:16:46 +0900 Subject: [PATCH 01/20] =?UTF-8?q?Style:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/message-page.jsx | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/pages/message-page.jsx b/src/pages/message-page.jsx index 0c9ae52..1f146da 100644 --- a/src/pages/message-page.jsx +++ b/src/pages/message-page.jsx @@ -105,12 +105,6 @@ export const SelectableImageItem = styled.li` 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; @@ -195,10 +189,7 @@ function MessagePage() { {selectableImages.map((image) => ( - + {`프로필 ))} From de72ae19b05b26a85968aee8eb90d768ec1cc90e Mon Sep 17 00:00:00 2001 From: yeseung00 Date: Fri, 14 Nov 2025 17:19:11 +0900 Subject: [PATCH 02/20] =?UTF-8?q?Feat:=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20UI=20=EB=94=94=EC=9E=90=EC=9D=B8,?= =?UTF-8?q?=20=EB=93=9C=EB=A1=AD=EB=8B=A4=EC=9A=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 생성하기 버튼에 공용 컴포넌트 사용 - 폼 인풋 스타일 적용 - ‘상대와의 관계’, ‘폰트선택’ 드롭다운 컴포넌트 구현# --- src/App.jsx | 6 +- src/components/message/drop-down.jsx | 159 +++++++++++++++++++++++++++ src/hooks/use-dropdown.js | 16 +++ src/pages/message-page.jsx | 126 ++++++++++----------- 4 files changed, 242 insertions(+), 65 deletions(-) create mode 100644 src/components/message/drop-down.jsx create mode 100644 src/hooks/use-dropdown.js diff --git a/src/App.jsx b/src/App.jsx index bacecaa..f9403a6 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -15,11 +15,11 @@ function App() { }> } /> - } /> + {/*} /> } /> } /> - } /> - } /> + } /> */} + } /> } /> } /> diff --git a/src/components/message/drop-down.jsx b/src/components/message/drop-down.jsx new file mode 100644 index 0000000..26512e0 --- /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/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/pages/message-page.jsx b/src/pages/message-page.jsx index 1f146da..9e42d09 100644 --- a/src/pages/message-page.jsx +++ b/src/pages/message-page.jsx @@ -1,9 +1,21 @@ import React from "react"; import styled, { css } from "styled-components"; +import { font } from "@/styles/font"; +import Button from "@/components/common/button"; +import DropDown from "@/components/message/drop-down"; +import useDropdown from "@/hooks/use-dropdown"; + +const RELATIONSHIP_OPTIONS = [ + { label: "지인", value: "지인" }, + { label: "친구", value: "친구" }, + { label: "동료", value: "동료" }, + { label: "가족", value: "가족" }, +]; + +const FONT_OPTIONS = [{ label: "Noto Sans", value: "Noto Sans" }]; const DEFAULT_ICON_URL = "/assets/default-user.svg"; const TEMP_IMAGE_URL = "/assets/temp-profile.jpg"; - const selectableImages = [ { id: 1, url: TEMP_IMAGE_URL, isSelected: true }, { id: 2, url: TEMP_IMAGE_URL, isSelected: false }, @@ -14,6 +26,21 @@ const selectableImages = [ { id: 7, url: TEMP_IMAGE_URL, isSelected: false }, ]; +const FormInputStyle = css` + width: 100%; + padding: 12px 16px; + border-radius: 8px; + border: 1px solid #ccc; + font-size: 16px; + outline: none; + color: #181818; + background-color: #fff; + + &:focus { + border-color: #555; + } +`; + export const PageContainer = styled.div` max-width: 720px; margin: 0 auto; @@ -33,19 +60,14 @@ export const FormField = styled.div` `; export const FormLabel = styled.label` - font-size: 16px; - font-weight: 700; - line-height: 26px; /* 162.5% */ + ${font.bold24} + line-height: 36px; + letter-spacing: -0.01em; color: #181818; `; 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; @@ -53,9 +75,9 @@ export const InputField = styled.input` `; export const ErrorMessage = styled.p` - color: #dc3545; /* 빨간색 계열 */ + color: #dc3545; font-size: 14px; - margin-top: -8px; /* 위쪽 갭 조정 */ + margin-top: -8px; `; export const ProfileWrapper = styled.div` @@ -75,7 +97,7 @@ export const ProfileDefaultBox = styled.div` height: 70px; border-radius: 50%; overflow: hidden; - flex-shrink: 0; /* 크기 고정 */ + flex-shrink: 0; border: 1px solid #ccc; img { @@ -89,8 +111,8 @@ export const SelectableImagesList = styled.ul` display: flex; gap: 4px; overflow-x: auto; - padding: 4px 0; /* 스크롤바를 위한 패딩 */ - -webkit-overflow-scrolling: touch; /* iOS에서 부드러운 스크롤 */ + padding: 4px 0; + -webkit-overflow-scrolling: touch; list-style: none; margin: 0; `; @@ -101,11 +123,10 @@ export const SelectableImageItem = styled.li` border-radius: 50%; overflow: hidden; cursor: pointer; - flex-shrink: 0; /* 크기 고정 */ + flex-shrink: 0; transition: transform 0.2s, border 0.2s; border: 2px solid transparent; - &:hover { opacity: 0.8; } @@ -117,17 +138,6 @@ export const SelectableImageItem = styled.li` } `; -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; @@ -138,33 +148,18 @@ export const EditorPlaceholder = styled.div` color: #777; `; -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 relationshipDropdown = useDropdown("지인"); + const fontDropdown = useDropdown("Noto Sans"); + return ( @@ -179,14 +174,14 @@ function MessagePage() { {/* 에러 메시지 표시 */} {hasError && "값을 입력해 주세요."} - {/* 프로필 이미지 선택창 */} + + {/* 프로필 이미지 선택창 (생략) */} 프로필 이미지 기본 프로필 이미지 - {selectableImages.map((image) => ( @@ -200,16 +195,14 @@ function MessagePage() { {/* 상대와의 관계 드롭다운*/} 상대와의 관계 - - - - - - + options={RELATIONSHIP_OPTIONS} + value={relationshipDropdown.value} + onChange={relationshipDropdown.handleChange} + /> {/* 내용 입력 (Rich Text Editor 사용) */} @@ -223,16 +216,25 @@ function MessagePage() { {/* 폰트 선택 드롭다운 */} 폰트 선택 - - - {/* 추가 폰트 옵션 추가 예정 */} - + {/* 생성하기 버튼 */} - + 생성하기 - + ); From f3a8d7d005b7b6367daa81fd3796573bcde06e6f Mon Sep 17 00:00:00 2001 From: yeseung00 Date: Fri, 14 Nov 2025 20:25:53 +0900 Subject: [PATCH 03/20] =?UTF-8?q?Feat:=20from.=20input=20=EB=B0=98?= =?UTF-8?q?=EC=9D=91=ED=98=95=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/message/drop-down.jsx | 7 ++-- src/components/message/from-input.jsx | 47 +++++++++++++++++++++++++++ src/hooks/use-from-input.js | 24 ++++++++++++++ src/pages/message-page.jsx | 23 ++++++++----- 4 files changed, 88 insertions(+), 13 deletions(-) create mode 100644 src/components/message/from-input.jsx create mode 100644 src/hooks/use-from-input.js diff --git a/src/components/message/drop-down.jsx b/src/components/message/drop-down.jsx index 26512e0..37d34df 100644 --- a/src/components/message/drop-down.jsx +++ b/src/components/message/drop-down.jsx @@ -12,7 +12,6 @@ const DropDownWrapper = styled.div` const DropDownTrigger = styled.button` width: 100%; - height: 50px; display: flex; justify-content: space-between; align-items: center; @@ -27,8 +26,8 @@ const DropDownTrigger = styled.button` outline: none; - color: ${({ currentValue, defaultValue, $isInitialLoad }) => { - if ($isInitialLoad && currentValue === defaultValue) { + color: ${({ $currentValue, defaultValue, $isInitialLoad }) => { + if ($isInitialLoad && $currentValue === defaultValue) { return colors.gray[500]; } return colors.gray[900]; @@ -128,7 +127,7 @@ function DropDown({ id, name, defaultValue, value, onChange, options }) { type="button" onClick={handleTriggerClick} $isOpen={isOpen} - currentValue={currentValue} + $currentValue={currentValue} defaultValue={defaultValue} $isInitialLoad={isInitialLoad} aria-haspopup="listbox" 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/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/pages/message-page.jsx b/src/pages/message-page.jsx index 9e42d09..421d11c 100644 --- a/src/pages/message-page.jsx +++ b/src/pages/message-page.jsx @@ -1,9 +1,12 @@ 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 useDropdown from "@/hooks/use-dropdown"; +import useFormInput from "@/hooks/use-from-input"; const RELATIONSHIP_OPTIONS = [ { label: "지인", value: "지인" }, @@ -26,14 +29,14 @@ const selectableImages = [ { id: 7, url: TEMP_IMAGE_URL, isSelected: false }, ]; -const FormInputStyle = css` +export const FormInputStyle = css` width: 100%; padding: 12px 16px; border-radius: 8px; border: 1px solid #ccc; - font-size: 16px; + ${font.regular16}; outline: none; - color: #181818; + ${colors.gray[900]}; background-color: #fff; &:focus { @@ -63,7 +66,7 @@ export const FormLabel = styled.label` ${font.bold24} line-height: 36px; letter-spacing: -0.01em; - color: #181818; + ${colors.gray[900]}; `; export const InputField = styled.input` @@ -155,8 +158,7 @@ const FullWidthButton = styled(Button)` function MessagePage() { const isFormValid = false; - const hasError = true; - + const fromInput = useFormInput(""); const relationshipDropdown = useDropdown("지인"); const fontDropdown = useDropdown("Noto Sans"); @@ -166,13 +168,16 @@ function MessagePage() { {/* From. 입력 필드 */} From. - - {/* 에러 메시지 표시 */} - {hasError && "값을 입력해 주세요."} {/* 프로필 이미지 선택창 (생략) */} From 18d966145fa43e6e5b5eb401c872cf09729fbc57 Mon Sep 17 00:00:00 2001 From: yeseung00 Date: Sat, 15 Nov 2025 04:00:12 +0900 Subject: [PATCH 04/20] =?UTF-8?q?Feat:=20reach=20text=20editor=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 89 +++++++++++++++++- package.json | 2 + src/components/message/reach-text-editor.jsx | 95 ++++++++++++++++++++ src/hooks/use-message-form.js | 55 ++++++++++++ src/pages/message-page.jsx | 63 ++++++------- 5 files changed, 263 insertions(+), 41 deletions(-) create mode 100644 src/components/message/reach-text-editor.jsx create mode 100644 src/hooks/use-message-form.js diff --git a/package-lock.json b/package-lock.json index 0982c14..8b81a7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,10 @@ "version": "0.0.0", "dependencies": { "axios": "^1.13.2", + "quill": "^2.0.3", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-quill-new": "^3.6.0", "react-router": "^7.9.5", "styled-components": "^6.1.19" }, @@ -2101,6 +2103,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", @@ -2108,6 +2116,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", @@ -2460,9 +2474,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": { @@ -2559,6 +2573,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", @@ -2708,6 +2741,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", @@ -2821,6 +2860,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", @@ -2842,6 +2910,21 @@ "react": "^19.2.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 8045356..fc182a8 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,10 @@ }, "dependencies": { "axios": "^1.13.2", + "quill": "^2.0.3", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-quill-new": "^3.6.0", "react-router": "^7.9.5", "styled-components": "^6.1.19" }, diff --git a/src/components/message/reach-text-editor.jsx b/src/components/message/reach-text-editor.jsx new file mode 100644 index 0000000..069e683 --- /dev/null +++ b/src/components/message/reach-text-editor.jsx @@ -0,0 +1,95 @@ +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 toolbar = Quill.import("modules/toolbar"); +const list = Quill.import("formats/list"); + +if (list) { + Quill.register(list, true); +} +if (toolbar) { +} + +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/hooks/use-message-form.js b/src/hooks/use-message-form.js new file mode 100644 index 0000000..07a9250 --- /dev/null +++ b/src/hooks/use-message-form.js @@ -0,0 +1,55 @@ +import { useState } from "react"; +import useDropdown from "@/hooks/use-dropdown"; +import useFormInput from "@/hooks/use-from-input"; + +const RELATIONSHIP_OPTIONS = [ + { label: "지인", value: "지인" }, + { label: "친구", value: "친구" }, + { label: "동료", value: "동료" }, + { label: "가족", value: "가족" }, +]; +const FONT_OPTIONS = [{ label: "Noto Sans", value: "Noto Sans" }]; + +export function useMessageForm() { + const fromInput = useFormInput(""); + const relationshipDropdown = useDropdown(RELATIONSHIP_OPTIONS[0].value); + const fontDropdown = useDropdown(FONT_OPTIONS[0].value); + const [editorContent, setEditorContent] = useState(""); + + const isContentValid = + editorContent.trim().length > 0 && editorContent !== "


"; + const isFormValid = fromInput.value.trim().length > 0 && isContentValid; + + const handleSubmit = (e) => { + e.preventDefault(); + + if (!isFormValid) { + alert("모든 필수 항목을 입력해 주세요."); + return; + } + + const formData = { + from: fromInput.value, + relationship: relationshipDropdown.value, + content: editorContent, + font: fontDropdown.value, + }; + + console.log("✅ 폼 데이터 제출 성공:", formData); + alert(` 메시지가 성공적으로 생성되었습니다.`); + + // TODO: 서버 API 호출 로직 구현 + }; + + return { + fromInput, + relationshipDropdown, + fontDropdown, + editorContent, + setEditorContent, + isFormValid, + handleSubmit, + RELATIONSHIP_OPTIONS, + FONT_OPTIONS, + }; +} diff --git a/src/pages/message-page.jsx b/src/pages/message-page.jsx index 421d11c..153e0ae 100644 --- a/src/pages/message-page.jsx +++ b/src/pages/message-page.jsx @@ -5,17 +5,8 @@ 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 useDropdown from "@/hooks/use-dropdown"; -import useFormInput from "@/hooks/use-from-input"; - -const RELATIONSHIP_OPTIONS = [ - { label: "지인", value: "지인" }, - { label: "친구", value: "친구" }, - { label: "동료", value: "동료" }, - { label: "가족", value: "가족" }, -]; - -const FONT_OPTIONS = [{ label: "Noto Sans", value: "Noto Sans" }]; +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"; @@ -33,27 +24,27 @@ export const FormInputStyle = css` width: 100%; padding: 12px 16px; border-radius: 8px; - border: 1px solid #ccc; + border: 1px solid ${colors.gray[300]}; ${font.regular16}; outline: none; ${colors.gray[900]}; background-color: #fff; &:focus { - border-color: #555; + 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` @@ -67,18 +58,20 @@ export const FormLabel = styled.label` line-height: 36px; letter-spacing: -0.01em; ${colors.gray[900]}; + margin: 0; + padding: 0; `; export const InputField = styled.input` ${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; `; @@ -101,7 +94,7 @@ export const ProfileDefaultBox = styled.div` border-radius: 50%; overflow: hidden; flex-shrink: 0; - border: 1px solid #ccc; + border: 1px solid ${colors.gray[300]}; img { width: 100%; @@ -141,30 +134,27 @@ export const SelectableImageItem = styled.li` } `; -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; -`; - const FullWidthButton = styled(Button)` width: 100%; margin-top: 20px; `; function MessagePage() { - const isFormValid = false; - const fromInput = useFormInput(""); - const relationshipDropdown = useDropdown("지인"); - const fontDropdown = useDropdown("Noto Sans"); + const { + fromInput, + relationshipDropdown, + fontDropdown, + editorContent, + setEditorContent, + isFormValid, + handleSubmit, + RELATIONSHIP_OPTIONS, + FONT_OPTIONS, + } = useMessageForm(); return ( - + {/* From. 입력 필드 */} From. @@ -180,7 +170,7 @@ function MessagePage() { /> - {/* 프로필 이미지 선택창 (생략) */} + {/* 프로필 이미지 선택창 */} 프로필 이미지 @@ -210,12 +200,9 @@ function MessagePage() { /> - {/* 내용 입력 (Rich Text Editor 사용) */} 내용을 입력해 주세요 - -

I am your reach text editor.

-
+
{/* 폰트 선택 드롭다운 */} From 3d0ccea1163223a922410bd64fbfb96c3fffd0e3 Mon Sep 17 00:00:00 2001 From: yeseung00 Date: Sun, 16 Nov 2025 03:00:43 +0900 Subject: [PATCH 05/20] =?UTF-8?q?=20Feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=84=A0=ED=83=9D=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 프로필 이미지 파일을 선택하지 않으면 기본 이미지를 넣도록 함 - 프로필을 선택시 colors.purple[700] 색의 테두리가 보이도록 함 --- .../message/profile-image-selector.jsx | 148 ++++++++++++++++++ src/hooks/use-message-form.js | 128 +++++++++++---- src/hooks/use-profile-image.js | 61 ++++++++ src/pages/message-page.jsx | 99 ++---------- 4 files changed, 319 insertions(+), 117 deletions(-) create mode 100644 src/components/message/profile-image-selector.jsx create mode 100644 src/hooks/use-profile-image.js 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/hooks/use-message-form.js b/src/hooks/use-message-form.js index 07a9250..fa5f3ae 100644 --- a/src/hooks/use-message-form.js +++ b/src/hooks/use-message-form.js @@ -1,44 +1,103 @@ -import { useState } from "react"; -import useDropdown from "@/hooks/use-dropdown"; -import useFormInput from "@/hooks/use-from-input"; - -const RELATIONSHIP_OPTIONS = [ - { label: "지인", value: "지인" }, - { label: "친구", value: "친구" }, - { label: "동료", value: "동료" }, - { label: "가족", value: "가족" }, +import { useState, useMemo } from "react"; +import { useProfileImage } from "./use-profile-image"; + +export const RELATIONSHIP_OPTIONS = [ + { value: "지인", label: "지인" }, + { value: "친구", label: "친구" }, + { value: "가족", label: "가족" }, + { value: "동료", label: "동료" }, ]; -const FONT_OPTIONS = [{ label: "Noto Sans", value: "Noto Sans" }]; -export function useMessageForm() { - const fromInput = useFormInput(""); - const relationshipDropdown = useDropdown(RELATIONSHIP_OPTIONS[0].value); - const fontDropdown = useDropdown(FONT_OPTIONS[0].value); +export const FONT_OPTIONS = [{ value: "Noto Sans", label: "Noto Sans" }]; + +const useInput = (initialValue) => { + const [value, setValue] = useState(initialValue); + const [hasError, setHasError] = useState(false); + const [isTouched, setIsTouched] = useState(false); + + const handleChange = (e) => { + setValue(e.target.value); + if (isTouched && e.target.value.trim() !== "") { + setHasError(false); + } + }; + + const handleBlur = () => { + setIsTouched(true); + if (value.trim() === "") { + setHasError(true); + } else { + setHasError(false); + } + }; + + return { + value, + hasError, + handleChange, + handleBlur, + isTouched, + isValid: value.trim() !== "", + reset: () => { + setValue(initialValue); + setHasError(false); + setIsTouched(false); + }, + }; +}; + +export const useMessageForm = () => { + const fromInput = useInput(""); + + const [relationship, setRelationship] = useState( + RELATIONSHIP_OPTIONS[0].value + ); + const relationshipDropdown = { + value: relationship, + handleChange: (e) => setRelationship(e.target.value), + }; + + const [font, setFont] = useState(FONT_OPTIONS[0].value); + const fontDropdown = { + value: font, + handleChange: (e) => setFont(e.target.value), + }; + const [editorContent, setEditorContent] = useState(""); - const isContentValid = - editorContent.trim().length > 0 && editorContent !== "


"; - const isFormValid = fromInput.value.trim().length > 0 && isContentValid; + const { + selectedProfileImageId, + handleImageSelect, + selectableImages, + isLoading, + error, + } = useProfileImage(); + + const isFormValid = useMemo(() => { + const isFromValid = fromInput.isValid; + const isContentValid = editorContent.trim().length > 0; + + return isFromValid && isContentValid; + }, [fromInput.isValid, editorContent]); const handleSubmit = (e) => { e.preventDefault(); - if (!isFormValid) { - alert("모든 필수 항목을 입력해 주세요."); - return; - } + fromInput.handleBlur(); - const formData = { - from: fromInput.value, - relationship: relationshipDropdown.value, - content: editorContent, - font: fontDropdown.value, - }; + if (isFormValid) { + const formData = { + from: fromInput.value, + relationship: relationship, + font: font, + content: editorContent, + profileImageId: selectedProfileImageId, + }; - console.log("✅ 폼 데이터 제출 성공:", formData); - alert(` 메시지가 성공적으로 생성되었습니다.`); - - // TODO: 서버 API 호출 로직 구현 + console.log("Form Submitted:", formData); + } else { + console.log("Form validation failed."); + } }; return { @@ -47,9 +106,14 @@ export function useMessageForm() { fontDropdown, editorContent, setEditorContent, + selectedProfileImageId, + handleImageSelect, + selectableImages, + isLoading, + error, isFormValid, handleSubmit, RELATIONSHIP_OPTIONS, FONT_OPTIONS, }; -} +}; diff --git a/src/hooks/use-profile-image.js b/src/hooks/use-profile-image.js new file mode 100644 index 0000000..57ab31f --- /dev/null +++ b/src/hooks/use-profile-image.js @@ -0,0 +1,61 @@ +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/pages/message-page.jsx b/src/pages/message-page.jsx index 153e0ae..09ed23a 100644 --- a/src/pages/message-page.jsx +++ b/src/pages/message-page.jsx @@ -8,17 +8,7 @@ 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"; -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 }, -]; +import ProfileImageSelector from "@/components/message/profile-image-selector"; export const FormInputStyle = css` width: 100%; @@ -76,64 +66,6 @@ export const ErrorMessage = styled.p` 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 ${colors.gray[300]}; - - 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; - 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; - &:hover { - opacity: 0.8; - } - - img { - width: 100%; - height: 100%; - object-fit: cover; - } -`; - const FullWidthButton = styled(Button)` width: 100%; margin-top: 20px; @@ -150,6 +82,11 @@ function MessagePage() { handleSubmit, RELATIONSHIP_OPTIONS, FONT_OPTIONS, + selectedProfileImageId, + handleImageSelect, + selectableImages, + isLoading, + error, } = useMessageForm(); return ( @@ -170,22 +107,14 @@ function MessagePage() { /> - {/* 프로필 이미지 선택창 */} - - 프로필 이미지 - - - 기본 프로필 이미지 - - - {selectableImages.map((image) => ( - - {`프로필 - - ))} - - - + {/* 프로필 이미지 선택 */} + {/* 상대와의 관계 드롭다운*/} From cc249f38fabf734e695cef7d76689805d408dced Mon Sep 17 00:00:00 2001 From: yeseung00 Date: Sun, 16 Nov 2025 03:23:06 +0900 Subject: [PATCH 06/20] =?UTF-8?q?Fix:=20Quill=20=EC=9E=84=ED=8F=AC?= =?UTF-8?q?=ED=8A=B8=20=EB=B0=8F=20=EB=93=B1=EB=A1=9D=20=EB=AC=B8=EC=A0=9C?= =?UTF-8?q?=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/message/reach-text-editor.jsx | 64 ++++++++++---------- 1 file changed, 31 insertions(+), 33 deletions(-) diff --git a/src/components/message/reach-text-editor.jsx b/src/components/message/reach-text-editor.jsx index 069e683..27abe38 100644 --- a/src/components/message/reach-text-editor.jsx +++ b/src/components/message/reach-text-editor.jsx @@ -1,20 +1,12 @@ -import React from "react"; +import React, { useMemo } 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 toolbar = Quill.import("modules/toolbar"); -const list = Quill.import("formats/list"); - -if (list) { - Quill.register(list, true); -} -if (toolbar) { -} const EditorContainer = styled.div` min-height: 243px; @@ -54,30 +46,36 @@ const EditorContainer = styled.div` `; function RichTextEditor({ value, onChange }) { - const modules = { - toolbar: [ - ["bold", "italic", "underline"], - [ - { align: "" }, - { align: "center" }, - { align: "right" }, - { align: "justify" }, + const modules = useMemo( + () => ({ + toolbar: [ + ["bold", "italic", "underline"], + [ + { align: "" }, + { align: "center" }, + { align: "right" }, + { align: "justify" }, + ], + [{ list: "ordered" }, { list: "bullet" }], + ["link", "image"], ], - [{ list: "ordered" }, { list: "bullet" }], - ["link", "image"], - ], - }; + }), + [] + ); - const formats = [ - "bold", - "italic", - "underline", - "align", - "list", - "bullet", - "link", - "image", - ]; + const formats = useMemo( + () => [ + "bold", + "italic", + "underline", + "align", + "list", + "bullet", + "link", + "image", + ], + [] + ); return ( From f1318cb62a10473b4e09be258517fb44fa5fd79d Mon Sep 17 00:00:00 2001 From: yeseung00 Date: Mon, 17 Nov 2025 11:21:20 +0900 Subject: [PATCH 07/20] =?UTF-8?q?Feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/use-message-form.js | 218 ++++++++++++++++++++++----------- src/hooks/use-profile-image.js | 4 +- 2 files changed, 145 insertions(+), 77 deletions(-) diff --git a/src/hooks/use-message-form.js b/src/hooks/use-message-form.js index fa5f3ae..64f6073 100644 --- a/src/hooks/use-message-form.js +++ b/src/hooks/use-message-form.js @@ -1,119 +1,189 @@ -import { useState, useMemo } from "react"; -import { useProfileImage } from "./use-profile-image"; +import { useState, useMemo, useEffect } from "react"; +import axios from "axios"; -export const RELATIONSHIP_OPTIONS = [ +// 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: "동료" }, ]; -export const FONT_OPTIONS = [{ value: "Noto Sans", label: "Noto Sans" }]; - -const useInput = (initialValue) => { +// 유효성 검사 훅 (이전과 동일) +const useInput = (initialValue, validate) => { const [value, setValue] = useState(initialValue); - const [hasError, setHasError] = useState(false); const [isTouched, setIsTouched] = useState(false); + const isValid = validate(value); + const hasError = isTouched && !isValid; + const handleChange = (e) => { setValue(e.target.value); - if (isTouched && e.target.value.trim() !== "") { - setHasError(false); - } }; const handleBlur = () => { setIsTouched(true); - if (value.trim() === "") { - setHasError(true); - } else { - setHasError(false); - } }; - return { - value, - hasError, - handleChange, - handleBlur, - isTouched, - isValid: value.trim() !== "", - reset: () => { - setValue(initialValue); - setHasError(false); - setIsTouched(false); - }, - }; + return { value, isValid, hasError, handleChange, handleBlur }; }; +// 메시지 폼 및 Axios API 로직 + export const useMessageForm = () => { - const fromInput = useInput(""); + // 상태 정의 - const [relationship, setRelationship] = useState( - RELATIONSHIP_OPTIONS[0].value + 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 relationshipDropdown = { - value: relationship, - handleChange: (e) => setRelationship(e.target.value), - }; - - const [font, setFont] = useState(FONT_OPTIONS[0].value); - const fontDropdown = { - value: font, - handleChange: (e) => setFont(e.target.value), - }; - const [editorContent, setEditorContent] = useState(""); - const { - selectedProfileImageId, - handleImageSelect, - selectableImages, - isLoading, - error, - } = useProfileImage(); - - const isFormValid = useMemo(() => { - const isFromValid = fromInput.isValid; - const isContentValid = editorContent.trim().length > 0; - - return isFromValid && isContentValid; - }, [fromInput.isValid, editorContent]); + 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, + ] + ); - const handleSubmit = (e) => { + // [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(); - fromInput.handleBlur(); + if (!isFormValid || isSubmitting) { + fromInput.handleBlur(); + return false; + } - if (isFormValid) { - const formData = { - from: fromInput.value, - relationship: relationship, - font: font, - content: editorContent, - profileImageId: selectedProfileImageId, - }; + // 프로필 이미지가 선택되었는지 확인 + if (selectedProfileImageId === 0) { + alert("프로필 이미지를 선택해 주세요."); + return false; + } - console.log("Form Submitted:", formData); - } else { - console.log("Form validation failed."); + 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, - selectedProfileImageId, - handleImageSelect, - selectableImages, - isLoading, - error, 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 index 57ab31f..dcc8917 100644 --- a/src/hooks/use-profile-image.js +++ b/src/hooks/use-profile-image.js @@ -5,9 +5,7 @@ import img2 from "@/assets/images/profile-img-02.webp"; export const DEFAULT_IMAGE_ID = 0; -/** - * 프로필 이미지 선택 상태와 로직 및 API 통신을 관리하는 커스텀 훅 - */ +/* 프로필 이미지 선택 상태와 로직 및 API 통신을 관리하는 커스텀 훅 */ export const useProfileImage = () => { const [selectedProfileImageId, setSelectedProfileImageId] = useState(DEFAULT_IMAGE_ID); From 7118beb10ef0c07605deb9e3a043c07825a64f8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EC=9B=90?= Date: Fri, 7 Nov 2025 05:47:37 +0900 Subject: [PATCH 08/20] =?UTF-8?q?Feat:=20=EB=A1=A4=EB=A7=81=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=ED=8D=BC=20=ED=97=A4=EB=8D=94=20UI=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/settings.json | 9 +- src/App.jsx | 2 +- src/components/head-nav.jsx | 9 ++ src/pages/rolling-page.jsx | 87 ++++++++++++++++ src/styles/head-nav-style.js | 12 +++ src/styles/rolling-page-styles.js | 167 ++++++++++++++++++++++++++++++ 6 files changed, 281 insertions(+), 5 deletions(-) create mode 100644 src/components/head-nav.jsx create mode 100644 src/pages/rolling-page.jsx create mode 100644 src/styles/head-nav-style.js create mode 100644 src/styles/rolling-page-styles.js diff --git a/.vscode/settings.json b/.vscode/settings.json index c86e487..62d54bc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,11 +5,12 @@ "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" }, - // 린트 적용 - "eslint.validate": ["javascript", "javascriptreact"], - + "eslint.validate": [ + "javascript", + "javascriptreact" + ], // JavaScript 관련 "javascript.preferences.importModuleSpecifier": "non-relative", "javascript.updateImportsOnFileMove.enabled": "always" -} +} \ No newline at end of file diff --git a/src/App.jsx b/src/App.jsx index f9403a6..08b96e5 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -17,7 +17,7 @@ function App() { } /> {/*} /> } /> - } /> + } /> } /> */} } /> } /> diff --git a/src/components/head-nav.jsx b/src/components/head-nav.jsx new file mode 100644 index 0000000..7f22bb5 --- /dev/null +++ b/src/components/head-nav.jsx @@ -0,0 +1,9 @@ +import { HeadNavContainer } from "@/styles/head-nav-style"; + +export default function HeadNav() { + return ( + +

navigation

+
+ ); +} \ No newline at end of file diff --git a/src/pages/rolling-page.jsx b/src/pages/rolling-page.jsx new file mode 100644 index 0000000..fa357c8 --- /dev/null +++ b/src/pages/rolling-page.jsx @@ -0,0 +1,87 @@ +import { + RollingHeaderContainer, + RollingHeaderUserInfo, + RollingHeaderRightContainer, + RollingHeaderUserPeopleContainer, + RollingHeaderUserPeopleImages, + RollingHeaderUserPeopleImage, + RollingHeaderUserDefaultImage, + RollingHeaderUserPeopleState, + RollingHeaderImojiContainer, + RollingHeaderArrowDown, + PerpendicularLine, + RollingHeaderImojiIconContainer, + RollingHeaderImojiText, + RollingHeaderImojiIcon, + RollingHeaderImojiEditButton, + RollingHeaderImojiEditButtonIcon, + RollingHeaderImojiEditButtonText, + RollingHeaderLinkShareButton, + RollingPageContainer, +} from "@/styles/rolling-page-styles"; +import HeadNav from "@/components/head-nav"; +import ArrowDownIcon from "@/assets/icons/arrow-down.svg"; +import AddEmojiIcon from "@/assets/icons/add-emoji.svg"; +import ShareIcon from "@/assets/icons/share.svg"; + +export default function RollingPage() { + return ( + <> + + + + To. Ashley Kim + + + + + {/* //여기에서 함수를 불러와서 처리해야함 */} + + + + + + + + + 23명이 작성 했어요! + + + + + + + {/* //여기에서 함수를 불러와서 처리해야함 */} + + 😘 + 12 + + + 😘 + 12 + + + 😘 + 12 + + + + + 추가 + + + + + + + + + + + + + + ); +} + + diff --git a/src/styles/head-nav-style.js b/src/styles/head-nav-style.js new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/src/styles/head-nav-style.js @@ -0,0 +1,12 @@ +import styled from "styled-components"; +import { colors } from "@/styles/colors"; + + +export const HeadNavContainer = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + height: 65px; + background-color: #fff; + border-bottom: 1px solid ${colors.gray[200]}; +`; \ No newline at end of file diff --git a/src/styles/rolling-page-styles.js b/src/styles/rolling-page-styles.js new file mode 100644 index 0000000..22ae3e7 --- /dev/null +++ b/src/styles/rolling-page-styles.js @@ -0,0 +1,167 @@ +import styled from "styled-components"; +import { colors } from "@/styles/colors"; +import { font } from "@/styles/font"; +import ShareIcon from "@/assets/icons/share.svg"; +//최상단헤더 컨테이너 +export const RollingHeaderContainer = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + width: 1200px; + margin: 0 auto; + padding: 13px 20px; + height: 65px; + background-color: background: rgba(255, 255, 255, 1); + +`; + +//유저 정보 컨테이너 TO. Ashley Kim +export const RollingHeaderUserInfo = styled.div` + display: flex; + align-items: center; + width: 227px; + height: 42px; + line-height: 42px; + letter-spacing: -1%; + ${font.bold28} + color: ${colors.gray[800]}; +`; + + +export const RollingHeaderRightContainer = styled.div` + display: flex; + align-items: center; + gap: 20px; +`; + + + +//유저 이미지 컨테이너 프로필 사진들과, 몇명이 작성중인지 표시 +export const RollingHeaderUserPeopleContainer = styled.div` + width: 228px; + display: flex; + align-items: center; + background-color: #fff; +`; + +//유저 이미지 프로필 사진들 +export const RollingHeaderUserPeopleImages = styled.div` + width: 76px; + height: 28px; + position: relative; + cursor: pointer; + +`; + +export const RollingHeaderUserPeopleImage = styled.img` + width: 28px; + height: 28px; + border-radius: 140px; + border: 1.4px solid #000; + position: relative; + margin-left: -10px; +`; + +export const RollingHeaderUserDefaultImage = styled(RollingHeaderUserPeopleImage)``; + +//몇명이 작성중인지 +export const RollingHeaderUserPeopleState = styled.div` + width: 160px; + height: 27px; + line-height: 27px; + ${font.bold18} + color: ${colors.gray[900]}; + text-align: center; +`; + +//이모지 컨테이너 드롭박스 포함, 추가 버튼 포함 +export const RollingHeaderImojiContainer = styled.div` + display: flex; + align-items: center; + gap: 8px; +`; + +export const RollingHeaderImojiIconContainer = styled.div` + display: inline-flex; + align-items: center; + justify-content: center; + width: auto; + height: 36px; + padding: 8px 12px; + text-align: center; + border-radius: 32px; + background: rgba(153, 153, 153, 1); + +`; + +export const RollingHeaderImojiIcon = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + color: rgba(255, 255, 255, 1); +`; + +export const RollingHeaderImojiText = styled.span` + ${font.regular16} + color: rgba(255, 255, 255, 1) +`; + +export const RollingHeaderImojiEditButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + width: 88px; + height: 36px; + border-radius: 6px; + background: #fff; + border: 1px solid ${colors.gray[300]}; + cursor: pointer; +`; + +export const RollingHeaderImojiEditButtonIcon = styled.img` + width: 20px; + height: 20px; +`; + +export const RollingHeaderImojiEditButtonText = styled.span` + ${font.regular16} + color: ${colors.gray[900]}; +`; + +export const RollingHeaderLinkShareButton = styled.div` + width: 56px; + height: 36px; + border-radius: 6px; + background-image: url("${ShareIcon}"); + background-size: 24px 24px; + background-repeat: no-repeat; + background-position: center; + padding: 12px 32px; + cursor: pointer; + border: 1px solid ${colors.gray[300]}; + +`; + +export const RollingHeaderArrowDown = styled.img` + width: 24px; + height: 24px; + cursor: pointer; +`; + +export const PerpendicularLine = styled.div` + border-left : 1px solid ${colors.gray[200]}; + height: 28px; +`; + + + +export const RollingPageContainer = styled.div` + background-color: ${colors.blue[100]}; + width: 100%; + margin: 0 auto; + padding: 20px; + height: 100vh; +`; \ No newline at end of file From 424f3eaf3385d8eede46cd65a455c2fd6e5ffb7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EC=9B=90?= Date: Fri, 7 Nov 2025 10:25:46 +0900 Subject: [PATCH 09/20] =?UTF-8?q?Feat:=20=EB=A1=A4=EB=A7=81=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=ED=8D=BC=20=ED=97=A4=EB=8D=94=20UI=20=ED=83=9C?= =?UTF-8?q?=EB=B8=94=EB=A6=BF,=EB=AA=A8=EB=B0=94=EC=9D=BC=20=EB=B0=98?= =?UTF-8?q?=EC=9D=91=ED=98=95=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/rolling-page.jsx | 24 ++-- src/styles/head-nav-style.js | 8 +- src/styles/rolling-page-styles.js | 175 +++++++++++++++++++++++++++--- 3 files changed, 181 insertions(+), 26 deletions(-) diff --git a/src/pages/rolling-page.jsx b/src/pages/rolling-page.jsx index fa357c8..9aacc03 100644 --- a/src/pages/rolling-page.jsx +++ b/src/pages/rolling-page.jsx @@ -9,10 +9,12 @@ import { RollingHeaderUserPeopleState, RollingHeaderImojiContainer, RollingHeaderArrowDown, - PerpendicularLine, + PerpendicularLineFirst, + PerpendicularLineSecond, RollingHeaderImojiIconContainer, RollingHeaderImojiText, RollingHeaderImojiIcon, + RollingHeaderImojiEditButtonContainer, RollingHeaderImojiEditButton, RollingHeaderImojiEditButtonIcon, RollingHeaderImojiEditButtonText, @@ -24,6 +26,7 @@ import ArrowDownIcon from "@/assets/icons/arrow-down.svg"; import AddEmojiIcon from "@/assets/icons/add-emoji.svg"; import ShareIcon from "@/assets/icons/share.svg"; + export default function RollingPage() { return ( <> @@ -49,7 +52,7 @@ export default function RollingPage() { - + {/* //여기에서 함수를 불러와서 처리해야함 */} @@ -65,13 +68,18 @@ export default function RollingPage() { 12 - - - 추가 - + + + + 추가 + + + + + + + - - diff --git a/src/styles/head-nav-style.js b/src/styles/head-nav-style.js index e952219..17d8de0 100644 --- a/src/styles/head-nav-style.js +++ b/src/styles/head-nav-style.js @@ -1,5 +1,6 @@ import styled from "styled-components"; import { colors } from "@/styles/colors"; +import media from "@/styles/media"; export const HeadNavContainer = styled.div` @@ -9,4 +10,9 @@ export const HeadNavContainer = styled.div` height: 65px; background-color: #fff; border-bottom: 1px solid ${colors.gray[200]}; -`; \ No newline at end of file + + ${media.small` + display: none; + `} + +`; diff --git a/src/styles/rolling-page-styles.js b/src/styles/rolling-page-styles.js index 22ae3e7..eae18ee 100644 --- a/src/styles/rolling-page-styles.js +++ b/src/styles/rolling-page-styles.js @@ -2,6 +2,9 @@ import styled from "styled-components"; import { colors } from "@/styles/colors"; import { font } from "@/styles/font"; import ShareIcon from "@/assets/icons/share.svg"; +import media from "@/styles/media"; + + //최상단헤더 컨테이너 export const RollingHeaderContainer = styled.div` display: flex; @@ -10,21 +13,72 @@ export const RollingHeaderContainer = styled.div` width: 1200px; margin: 0 auto; padding: 13px 20px; - height: 65px; - background-color: background: rgba(255, 255, 255, 1); + height: 68px; + background-color: rgba(255, 255, 255, 1); + gap: 20px; + + ${media.large` + width: 1200px; + height: 68px; + margin: 0 auto; + padding: 13px 20px; + overflow-x: auto; + overflow-y: hidden; + gap: 20px; + `} + + ${media.medium` + width: 100%; + height: 68px; + margin: 0; + padding: 13px 20px; + overflow-x: auto; + overflow-y: hidden; + gap: 10px; + `} + + ${media.small` + flex-direction: column; + align-items: center; + height: auto; + width: 100%; + padding: 0px; + gap: 0px; + + `} `; + + + //유저 정보 컨테이너 TO. Ashley Kim export const RollingHeaderUserInfo = styled.div` display: flex; align-items: center; - width: 227px; + min-width: 227px; height: 42px; line-height: 42px; letter-spacing: -1%; ${font.bold28} color: ${colors.gray[800]}; + flex-shrink: 0; + + ${media.medium` + min-width: 150px; + height: 42px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + `} + + ${media.small` + width: 100%; + min-width: auto; + height: auto; + padding: 12px 20px; + border-bottom: 1px solid ${colors.gray[200]}; + `} `; @@ -32,6 +86,24 @@ export const RollingHeaderRightContainer = styled.div` display: flex; align-items: center; gap: 20px; + flex-shrink: 1; + min-width: 0; + + ${media.medium` + gap: 8px; + flex-shrink: 1; + min-width: 0; + + `} + + ${media.small` + width: 100%; + padding: 8px 20px; + + + `} + + `; @@ -41,7 +113,14 @@ export const RollingHeaderUserPeopleContainer = styled.div` width: 228px; display: flex; align-items: center; - background-color: #fff; + + ${media.medium` + display: none; + `} + + ${media.small` + display: none; + `} `; //유저 이미지 프로필 사진들 @@ -50,6 +129,15 @@ export const RollingHeaderUserPeopleImages = styled.div` height: 28px; position: relative; cursor: pointer; + ${media.medium` + + display: none; + `} + + ${media.small` + + display: none; + `} `; @@ -72,6 +160,14 @@ export const RollingHeaderUserPeopleState = styled.div` ${font.bold18} color: ${colors.gray[900]}; text-align: center; + ${media.medium` + + display: none; + `} + + ${media.small` + display: none; + `} `; //이모지 컨테이너 드롭박스 포함, 추가 버튼 포함 @@ -79,28 +175,38 @@ export const RollingHeaderImojiContainer = styled.div` display: flex; align-items: center; gap: 8px; + `; export const RollingHeaderImojiIconContainer = styled.div` - display: inline-flex; + display: flex; align-items: center; justify-content: center; width: auto; - height: 36px; + height: auto; padding: 8px 12px; text-align: center; border-radius: 32px; background: rgba(153, 153, 153, 1); + gap: 2px; + + ${media.small` + padding: 4px 8px; + `} `; export const RollingHeaderImojiIcon = styled.div` - display: flex; - align-items: center; - justify-content: center; + width: 24px; height: 24px; color: rgba(255, 255, 255, 1); + + ${media.small` + width: 20px; + height: 24px; + `} + `; export const RollingHeaderImojiText = styled.span` @@ -119,6 +225,16 @@ export const RollingHeaderImojiEditButton = styled.button` background: #fff; border: 1px solid ${colors.gray[300]}; cursor: pointer; + ${media.small` + width: 36px; + height: 32px; + `} +`; + +export const RollingHeaderImojiEditButtonContainer = styled.div` + display: flex; + align-items: center; + gap: 15px; `; export const RollingHeaderImojiEditButtonIcon = styled.img` @@ -129,6 +245,9 @@ export const RollingHeaderImojiEditButtonIcon = styled.img` export const RollingHeaderImojiEditButtonText = styled.span` ${font.regular16} color: ${colors.gray[900]}; + ${media.small` + display: none; + `} `; export const RollingHeaderLinkShareButton = styled.div` @@ -142,6 +261,13 @@ export const RollingHeaderLinkShareButton = styled.div` padding: 12px 32px; cursor: pointer; border: 1px solid ${colors.gray[300]}; + ${media.small` + width: 36px; + height: 32px; + background-size: 20px 20px; + padding: 8px 8px; + + `} `; @@ -151,17 +277,32 @@ export const RollingHeaderArrowDown = styled.img` cursor: pointer; `; + + + +export const RollingPageContainer = styled.div` +background-color: ${colors.blue[100]}; +width: 100%; +margin: 0 auto; +padding: 20px; +height: 100vh; +`; + + + export const PerpendicularLine = styled.div` - border-left : 1px solid ${colors.gray[200]}; + border-left: 1px solid ${colors.gray[200]}; height: 28px; `; +export const PerpendicularLineFirst = styled(PerpendicularLine)` + ${media.medium` + display: none; + `} + ${media.small` + display: none; + `} +`; -export const RollingPageContainer = styled.div` - background-color: ${colors.blue[100]}; - width: 100%; - margin: 0 auto; - padding: 20px; - height: 100vh; -`; \ No newline at end of file +export const PerpendicularLineSecond = styled(PerpendicularLine)``; \ No newline at end of file From 5cf2a45c6d1f3a0fbf856e92a5e92ce59b17416e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EC=9B=90?= Date: Sun, 9 Nov 2025 02:29:51 +0900 Subject: [PATCH 10/20] =?UTF-8?q?Feat:=20=EB=A1=A4=EB=A7=81=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=ED=8D=BC=20=ED=97=A4=EB=8D=94=20=EC=9D=B4=EB=AA=A8?= =?UTF-8?q?=EC=A7=80=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 22 +++ package.json | 1 + src/pages/rolling-page-head.jsx | 235 ++++++++++++++++++++++++++++++ src/pages/rolling-page.jsx | 45 +----- src/styles/rolling-page-styles.js | 2 - 5 files changed, 265 insertions(+), 40 deletions(-) create mode 100644 src/pages/rolling-page-head.jsx diff --git a/package-lock.json b/package-lock.json index 8b81a7d..7e44d9c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "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", @@ -1816,6 +1817,21 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-picker-react": { + "version": "4.15.0", + "resolved": "https://registry.npmjs.org/emoji-picker-react/-/emoji-picker-react-4.15.0.tgz", + "integrity": "sha512-41y22VM7Gamcrxkgm3dKO2pAaa56AtFbK2bBImZbaME1jh0C39vlI2qPWCNFVf96HOYN4SLLSE2alRSyC3QYYQ==", + "license": "MIT", + "dependencies": { + "flairup": "1.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2184,6 +2200,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/flairup": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/flairup/-/flairup-1.0.0.tgz", + "integrity": "sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==", + "license": "MIT" + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", diff --git a/package.json b/package.json index fc182a8..e673689 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "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", diff --git a/src/pages/rolling-page-head.jsx b/src/pages/rolling-page-head.jsx new file mode 100644 index 0000000..5245fd6 --- /dev/null +++ b/src/pages/rolling-page-head.jsx @@ -0,0 +1,235 @@ +import React, { useState } from 'react'; +import EmojiPicker from 'emoji-picker-react'; +import styled from 'styled-components'; +import { colors } from '@/styles/colors'; +import media from '@/styles/media'; +import { font } from '@/styles/font'; +import { + RollingHeaderImojiContainer, + RollingHeaderImojiIconContainer, + RollingHeaderImojiText, + RollingHeaderImojiIcon, + RollingHeaderImojiEditButtonContainer, + RollingHeaderImojiEditButton, + RollingHeaderImojiEditButtonIcon, + RollingHeaderImojiEditButtonText, + RollingHeaderArrowDown, + PerpendicularLineSecond, + RollingHeaderLinkShareButton, +} from '@/styles/rolling-page-styles'; + +const EmojiPickerContainer = styled.div` + position: relative; + display: inline-block; +`; + +const EmojiPickerWrapper = styled.div` + position: fixed; + transform: translate(-60%, 2%); + z-index: 1000; + background: white; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + border: 1px solid ${colors.gray[300]}; + +`; + +const Overlay = styled.div` + position: fixed; + top: 0; + left: 0; + + background: transparent; + z-index: 999; +`; + +// 이모지 드롭다운 관련 스타일 +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) +`; + +// 이모지 피커 컴포넌트 +function EmojiPickerComponent({ isOpen, onClose, onEmojiSelect, children }) { + const handleEmojiClick = (emojiData) => { + onEmojiSelect(emojiData.emoji); + onClose(); + }; + + return ( + + {children} + {isOpen && ( + <> + + + + + + )} + + ); +} + +// 롤링 페이지 헤더 컴포넌트 +export default function RollingPageHeader({ + ArrowDownIcon, + AddEmojiIcon, + ShareIcon +}) { + const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false); + const [isEmojiDropdownOpen, setIsEmojiDropdownOpen] = useState(false); + const [selectedEmojis, setSelectedEmojis] = useState([ + { emoji: '😘', count: 12 }, + { emoji: '😍', count: 8 }, + { emoji: '👍', count: 15 }, + { emoji: '🎉', count: 5 }, + { emoji: '❤️', count: 20 }, + { emoji: '😂', count: 3 }, + { emoji: '🔥', count: 7 } + ]); + + const handleEmojiSelect = (emoji) => { + const existingEmojiIndex = selectedEmojis.findIndex(item => item.emoji === emoji); + + if (existingEmojiIndex !== -1) { + // 이미 존재하는 이모지면 카운트 증가 + const updatedEmojis = [...selectedEmojis]; + updatedEmojis[existingEmojiIndex].count += 1; + setSelectedEmojis(updatedEmojis); + } else { + // 새로운 이모지면 추가 + setSelectedEmojis([...selectedEmojis, { emoji, count: 1 }]); + } + }; + + const toggleEmojiPicker = () => { + setIsEmojiPickerOpen(!isEmojiPickerOpen); + }; + + const closeEmojiPicker = () => { + setIsEmojiPickerOpen(false); + }; + + const toggleEmojiDropdown = () => { + setIsEmojiDropdownOpen(!isEmojiDropdownOpen); + }; + + const closeEmojiDropdown = () => { + setIsEmojiDropdownOpen(false); + }; + + // 카운트 순으로 정렬하여 상위 3개만 추출 + const sortedEmojis = [...selectedEmojis].sort((a, b) => b.count - a.count); + const topThreeEmojis = sortedEmojis.slice(0, 3); + const hasMoreEmojis = selectedEmojis.length > 3; + + return ( + + {topThreeEmojis.map((emojiData, index) => ( + + {emojiData.emoji} + {emojiData.count} + + ))} + + {hasMoreEmojis && ( + + + {isEmojiDropdownOpen && ( + <> + + + + {sortedEmojis.map((emojiData, index) => ( + + {emojiData.emoji} + {emojiData.count} + + ))} + + + + )} + + )} + + + + + + 추가 + + + + + + + ); +} \ No newline at end of file diff --git a/src/pages/rolling-page.jsx b/src/pages/rolling-page.jsx index 9aacc03..2c4aea1 100644 --- a/src/pages/rolling-page.jsx +++ b/src/pages/rolling-page.jsx @@ -1,3 +1,4 @@ +import React from 'react'; import { RollingHeaderContainer, RollingHeaderUserInfo, @@ -7,21 +8,11 @@ import { RollingHeaderUserPeopleImage, RollingHeaderUserDefaultImage, RollingHeaderUserPeopleState, - RollingHeaderImojiContainer, - RollingHeaderArrowDown, PerpendicularLineFirst, - PerpendicularLineSecond, - RollingHeaderImojiIconContainer, - RollingHeaderImojiText, - RollingHeaderImojiIcon, - RollingHeaderImojiEditButtonContainer, - RollingHeaderImojiEditButton, - RollingHeaderImojiEditButtonIcon, - RollingHeaderImojiEditButtonText, - RollingHeaderLinkShareButton, RollingPageContainer, } from "@/styles/rolling-page-styles"; import HeadNav from "@/components/head-nav"; +import RollingPageHeader from "@/pages/rolling-page-head"; import ArrowDownIcon from "@/assets/icons/arrow-down.svg"; import AddEmojiIcon from "@/assets/icons/add-emoji.svg"; import ShareIcon from "@/assets/icons/share.svg"; @@ -53,33 +44,11 @@ export default function RollingPage() { - - {/* //여기에서 함수를 불러와서 처리해야함 */} - - 😘 - 12 - - - 😘 - 12 - - - 😘 - 12 - - - - - - 추가 - - - - - - - - + diff --git a/src/styles/rolling-page-styles.js b/src/styles/rolling-page-styles.js index eae18ee..34ccb73 100644 --- a/src/styles/rolling-page-styles.js +++ b/src/styles/rolling-page-styles.js @@ -50,8 +50,6 @@ export const RollingHeaderContainer = styled.div` `; - - //유저 정보 컨테이너 TO. Ashley Kim export const RollingHeaderUserInfo = styled.div` display: flex; From 70474b0c8310ac9d1a4bf274de27206fb07b3e15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EC=9B=90?= Date: Sun, 9 Nov 2025 05:55:07 +0900 Subject: [PATCH 11/20] =?UTF-8?q?Feat:=20upstream=20develop=20=EB=B8=8C?= =?UTF-8?q?=EB=9E=9C=EC=B9=98=20merge,=20=EC=B9=B4=EC=B9=B4=EC=98=A4?= =?UTF-8?q?=ED=86=A1=20=EA=B3=B5=EC=9C=A0=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + src/App.jsx | 26 ++-- src/components/common/share-modal.jsx | 180 ++++++++++++++++++++++++++ src/components/head-nav.jsx | 9 -- src/contexts/toast-context-state.jsx | 5 + src/pages/rolling-page-head.jsx | 25 +++- src/pages/rolling-page.jsx | 2 - 7 files changed, 226 insertions(+), 23 deletions(-) create mode 100644 src/components/common/share-modal.jsx delete mode 100644 src/components/head-nav.jsx create mode 100644 src/contexts/toast-context-state.jsx diff --git a/.gitignore b/.gitignore index cb0da3d..fd32680 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ dist-ssr *.njsproj *.sln *.sw? + +.env \ No newline at end of file diff --git a/src/App.jsx b/src/App.jsx index 08b96e5..6d3af8e 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,5 +1,6 @@ import { Routes, Route } from "react-router"; import { GlobalStyle } from "@/styles/global-style"; +import ToastProvider from "@/contexts/toast-context"; import GlobalLayout from "@/components/common/global-layout"; import TestPage from "@/pages/test-page"; import MessagePage from "@/pages/message-page"; @@ -12,18 +13,21 @@ function App() { return ( <> - - }> - } /> - {/*} /> - } /> + + + }> + } /> + {/* } /> + } /> } /> - } /> */} - } /> - } /> - } /> - - + } /> + } /> */} + } /> + } /> + } /> + + + ); } diff --git a/src/components/common/share-modal.jsx b/src/components/common/share-modal.jsx new file mode 100644 index 0000000..0e27840 --- /dev/null +++ b/src/components/common/share-modal.jsx @@ -0,0 +1,180 @@ +import React, { useEffect } from 'react'; +import styled from 'styled-components'; +import { colors } from '@/styles/colors'; +import { font } from '@/styles/font'; +import media from '@/styles/media'; +import useToast from '@/hooks/use-toast'; + +const KAKAO_KEY = import.meta.env.VITE_KAKAO_KEY; +console.log(KAKAO_KEY); +const Overlay = styled.div` + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.5); + z-index: 1000; + display: flex; + justify-content: center; + align-items: center; +`; + +const ModalContainer = styled.div` + background: white; + border-radius: 16px; + padding: 40px; + width: 480px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + + ${media.medium` + width: 400px; + padding: 30px; + `} + + ${media.small` + width: 320px; + padding: 24px; + `} +`; + +const ModalTitle = styled.h2` + ${font.bold24} + color: ${colors.gray[900]}; + margin-bottom: 24px; + text-align: center; +`; + +const ShareButtonGroup = styled.div` + display: flex; + flex-direction: column; + gap: 12px; +`; + +const ShareButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + width: 100%; + padding: 16px; + background: ${colors.gray[100]}; + border: 1px solid ${colors.gray[300]}; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; + ${font.regular16} + color: ${colors.gray[900]}; + + &:hover { + background: ${colors.gray[200]}; + border-color: ${colors.gray[400]}; + } + + &:active { + transform: scale(0.98); + } +`; + +const KakaoButton = styled(ShareButton)` + background: #fee500; + border-color: #fee500; + color: #000000; + + &:hover { + background: #fdd835; + border-color: #fdd835; + } +`; + +const CloseButton = styled.button` + width: 100%; + margin-top: 16px; + padding: 6px; + background: transparent; + border: 1px solid ${colors.gray[300]}; + border-radius: 8px; + cursor: pointer; + ${font.regular16} + color: ${colors.gray[700]}; + transition: all 0.2s; + + &:hover { + background: ${colors.gray[50]}; + } +`; + +export default function ShareModal({ isOpen, onClose, shareUrl }) { + const { showToast } = useToast(); + + // 카카오 SDK 초기화 + useEffect(() => { + if (!window.Kakao) { + const script = document.createElement('script'); + script.src = 'https://t1.kakaocdn.net/kakao_js_sdk/2.7.7/kakao.min.js'; + script.integrity = 'sha384-tJkjbtDbvoxO+diRuDtwRO9JXR7pjWnfjfRn5ePUpl7e7RJCxKCwwnfqUAdXh53p'; + script.crossOrigin = 'anonymous'; + script.async = true; + + script.onload = () => { + if (window.Kakao && !window.Kakao.isInitialized()) { + window.Kakao.init(KAKAO_KEY); + } + }; + + document.head.appendChild(script); + } else if (!window.Kakao.isInitialized()) { + window.Kakao.init(KAKAO_KEY); + } + }, []); + + // URL 복사 기능 + const handleCopyUrl = async () => { + try { + await navigator.clipboard.writeText(shareUrl); + showToast('URL이 복사되었습니다.', 'success'); + onClose(); + } catch (err) { + console.error('URL 복사 실패:', err); + showToast('URL 복사에 실패했습니다.', 'delete'); + } + }; + + // 카카오톡 공유 기능 + const handleKakaoShare = () => { + if (window.Kakao) { + try { + window.Kakao.Share.sendScrap({ + requestUrl: shareUrl, + }); + } catch (err) { + console.error('카카오톡 공유 실패:', err); + showToast(true, '카카오톡 공유에 실패했습니다.'); + } + } else { + showToast(true, '카카오톡 SDK가 로드되지 않았습니다.'); + } + }; + + if (!isOpen) return null; + + return ( + + e.stopPropagation()}> + 공유하기 + + + 🗨️ + 카카오톡으로 공유하기 + + + 🔗 + URL 복사하기 + + + 닫기 + + + ); +} + diff --git a/src/components/head-nav.jsx b/src/components/head-nav.jsx deleted file mode 100644 index 7f22bb5..0000000 --- a/src/components/head-nav.jsx +++ /dev/null @@ -1,9 +0,0 @@ -import { HeadNavContainer } from "@/styles/head-nav-style"; - -export default function HeadNav() { - return ( - -

navigation

-
- ); -} \ No newline at end of file diff --git a/src/contexts/toast-context-state.jsx b/src/contexts/toast-context-state.jsx new file mode 100644 index 0000000..a5eb1e9 --- /dev/null +++ b/src/contexts/toast-context-state.jsx @@ -0,0 +1,5 @@ +import React, { createContext } from 'react'; + +export const ToastContext = createContext(); + + diff --git a/src/pages/rolling-page-head.jsx b/src/pages/rolling-page-head.jsx index 5245fd6..707c2f8 100644 --- a/src/pages/rolling-page-head.jsx +++ b/src/pages/rolling-page-head.jsx @@ -4,6 +4,7 @@ import styled from 'styled-components'; import { colors } from '@/styles/colors'; import media from '@/styles/media'; import { font } from '@/styles/font'; +import ShareModal from '@/components/common/share-modal'; import { RollingHeaderImojiContainer, RollingHeaderImojiIconContainer, @@ -138,6 +139,7 @@ export default function RollingPageHeader({ }) { const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false); const [isEmojiDropdownOpen, setIsEmojiDropdownOpen] = useState(false); + const [isShareModalOpen, setIsShareModalOpen] = useState(false); const [selectedEmojis, setSelectedEmojis] = useState([ { emoji: '😘', count: 12 }, { emoji: '😍', count: 8 }, @@ -178,11 +180,22 @@ export default function RollingPageHeader({ setIsEmojiDropdownOpen(false); }; + const openShareModal = () => { + setIsShareModalOpen(true); + }; + + const closeShareModal = () => { + setIsShareModalOpen(false); + }; + // 카운트 순으로 정렬하여 상위 3개만 추출 const sortedEmojis = [...selectedEmojis].sort((a, b) => b.count - a.count); const topThreeEmojis = sortedEmojis.slice(0, 3); const hasMoreEmojis = selectedEmojis.length > 3; + // 현재 페이지 URL 가져오기 + const currentUrl = window.location.href; + return ( {topThreeEmojis.map((emojiData, index) => ( @@ -228,8 +241,18 @@ export default function RollingPageHeader({ - + + + ); } \ No newline at end of file diff --git a/src/pages/rolling-page.jsx b/src/pages/rolling-page.jsx index 2c4aea1..749a8c8 100644 --- a/src/pages/rolling-page.jsx +++ b/src/pages/rolling-page.jsx @@ -11,7 +11,6 @@ import { PerpendicularLineFirst, RollingPageContainer, } from "@/styles/rolling-page-styles"; -import HeadNav from "@/components/head-nav"; import RollingPageHeader from "@/pages/rolling-page-head"; import ArrowDownIcon from "@/assets/icons/arrow-down.svg"; import AddEmojiIcon from "@/assets/icons/add-emoji.svg"; @@ -21,7 +20,6 @@ import ShareIcon from "@/assets/icons/share.svg"; export default function RollingPage() { return ( <> - To. Ashley Kim From 1ddb7ac87a1bc42fb9f69a13eae19d5a462d2bda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EC=9B=90?= Date: Tue, 11 Nov 2025 03:17:28 +0900 Subject: [PATCH 12/20] =?UTF-8?q?Feat:=20=EA=B3=B5=EC=9C=A0=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20UI=20=EC=88=98=EC=A0=95,=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=EC=9D=B4=EB=AF=B8=EC=A7=80=EC=99=80=20=EB=AA=87?= =?UTF-8?q?=EB=AA=85=20=EC=9E=91=EC=84=B1=ED=96=88=EB=8A=94=EC=A7=80=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=ED=95=98=EB=8A=94=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/header.jsx | 1 + src/components/common/modal-layout.jsx | 82 ++++++ src/components/common/share-modal.jsx | 180 ------------- src/components/rolling/emoji-display-list.jsx | 24 ++ src/components/rolling/emoji-dropdown.jsx | 108 ++++++++ .../rolling/emoji-picker-component.jsx | 62 +++++ .../rolling/header-action-buttons.jsx | 55 ++++ .../rolling/participant-section.jsx | 34 +++ src/components/rolling/participant-stats.jsx | 23 ++ src/components/rolling/profile-image-list.jsx | 25 ++ .../rolling/profile-overflow-badge.jsx | 32 +++ src/components/rolling/share-button-group.jsx | 70 +++++ src/components/rolling/share-modal.jsx | 55 ++++ src/hooks/use-emoji-manager.js | 41 +++ src/hooks/use-kakao-sdk.js | 41 +++ src/hooks/use-profile-images.js | 36 +++ src/hooks/use-share-actions.js | 52 ++++ src/pages/rolling-page-head.jsx | 254 ++++-------------- src/pages/rolling-page.jsx | 42 ++- src/styles/rolling-page-styles.js | 10 +- 20 files changed, 814 insertions(+), 413 deletions(-) create mode 100644 src/components/common/modal-layout.jsx delete mode 100644 src/components/common/share-modal.jsx create mode 100644 src/components/rolling/emoji-display-list.jsx create mode 100644 src/components/rolling/emoji-dropdown.jsx create mode 100644 src/components/rolling/emoji-picker-component.jsx create mode 100644 src/components/rolling/header-action-buttons.jsx create mode 100644 src/components/rolling/participant-section.jsx create mode 100644 src/components/rolling/participant-stats.jsx create mode 100644 src/components/rolling/profile-image-list.jsx create mode 100644 src/components/rolling/profile-overflow-badge.jsx create mode 100644 src/components/rolling/share-button-group.jsx create mode 100644 src/components/rolling/share-modal.jsx create mode 100644 src/hooks/use-emoji-manager.js create mode 100644 src/hooks/use-kakao-sdk.js create mode 100644 src/hooks/use-profile-images.js create mode 100644 src/hooks/use-share-actions.js diff --git a/src/components/common/header.jsx b/src/components/common/header.jsx index 6c4ee3f..44210f8 100644 --- a/src/components/common/header.jsx +++ b/src/components/common/header.jsx @@ -8,6 +8,7 @@ const ContainWrapper = styled.div` top: 0; background-color: white; border-bottom: 1px solid #ededed; + z-index: 1003; `; const Contain = styled.div` diff --git a/src/components/common/modal-layout.jsx b/src/components/common/modal-layout.jsx new file mode 100644 index 0000000..af54b7a --- /dev/null +++ b/src/components/common/modal-layout.jsx @@ -0,0 +1,82 @@ +import React from 'react'; +import styled from 'styled-components'; +import { colors } from '@/styles/colors'; +import { font } from '@/styles/font'; +import media from '@/styles/media'; + +const Overlay = styled.div` + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: transparent; + z-index: 999; +`; + +const ModalContainer = styled.div` + background: white; + border-radius: 16px; + padding: 40px; + width: 480px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + + ${media.medium` + width: 400px; + padding: 30px; + `} + + ${media.small` + width: 320px; + padding: 24px; + `} +`; + +const ModalTitle = styled.h2` + ${font.bold24} + color: ${colors.gray[900]}; + margin-bottom: 24px; + text-align: center; +`; + +const ModalContent = styled.div` + width: 100%; +`; + +const CloseButton = styled.button` + width: 100%; + margin-top: 16px; + padding: 6px; + background: transparent; + border: 1px solid ${colors.gray[300]}; + border-radius: 8px; + cursor: pointer; + ${font.regular16} + color: ${colors.gray[700]}; + transition: all 0.2s; + + &:hover { + background: ${colors.gray[50]}; + } +`; + +/** + * 공통 모달 레이아웃 컴포넌트 + * 책임: 모달의 기본 구조와 레이아웃 제공 + */ +export default function ModalLayout({ isOpen, onClose, title, children, showCloseButton = true }) { + if (!isOpen) return null; + + return ( + + e.stopPropagation()}> + {title && {title}} + {children} + {showCloseButton && ( + 닫기 + )} + + + ); +} + diff --git a/src/components/common/share-modal.jsx b/src/components/common/share-modal.jsx deleted file mode 100644 index 0e27840..0000000 --- a/src/components/common/share-modal.jsx +++ /dev/null @@ -1,180 +0,0 @@ -import React, { useEffect } from 'react'; -import styled from 'styled-components'; -import { colors } from '@/styles/colors'; -import { font } from '@/styles/font'; -import media from '@/styles/media'; -import useToast from '@/hooks/use-toast'; - -const KAKAO_KEY = import.meta.env.VITE_KAKAO_KEY; -console.log(KAKAO_KEY); -const Overlay = styled.div` - position: fixed; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - background: rgba(0, 0, 0, 0.5); - z-index: 1000; - display: flex; - justify-content: center; - align-items: center; -`; - -const ModalContainer = styled.div` - background: white; - border-radius: 16px; - padding: 40px; - width: 480px; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); - - ${media.medium` - width: 400px; - padding: 30px; - `} - - ${media.small` - width: 320px; - padding: 24px; - `} -`; - -const ModalTitle = styled.h2` - ${font.bold24} - color: ${colors.gray[900]}; - margin-bottom: 24px; - text-align: center; -`; - -const ShareButtonGroup = styled.div` - display: flex; - flex-direction: column; - gap: 12px; -`; - -const ShareButton = styled.button` - display: flex; - align-items: center; - justify-content: center; - gap: 12px; - width: 100%; - padding: 16px; - background: ${colors.gray[100]}; - border: 1px solid ${colors.gray[300]}; - border-radius: 8px; - cursor: pointer; - transition: all 0.2s; - ${font.regular16} - color: ${colors.gray[900]}; - - &:hover { - background: ${colors.gray[200]}; - border-color: ${colors.gray[400]}; - } - - &:active { - transform: scale(0.98); - } -`; - -const KakaoButton = styled(ShareButton)` - background: #fee500; - border-color: #fee500; - color: #000000; - - &:hover { - background: #fdd835; - border-color: #fdd835; - } -`; - -const CloseButton = styled.button` - width: 100%; - margin-top: 16px; - padding: 6px; - background: transparent; - border: 1px solid ${colors.gray[300]}; - border-radius: 8px; - cursor: pointer; - ${font.regular16} - color: ${colors.gray[700]}; - transition: all 0.2s; - - &:hover { - background: ${colors.gray[50]}; - } -`; - -export default function ShareModal({ isOpen, onClose, shareUrl }) { - const { showToast } = useToast(); - - // 카카오 SDK 초기화 - useEffect(() => { - if (!window.Kakao) { - const script = document.createElement('script'); - script.src = 'https://t1.kakaocdn.net/kakao_js_sdk/2.7.7/kakao.min.js'; - script.integrity = 'sha384-tJkjbtDbvoxO+diRuDtwRO9JXR7pjWnfjfRn5ePUpl7e7RJCxKCwwnfqUAdXh53p'; - script.crossOrigin = 'anonymous'; - script.async = true; - - script.onload = () => { - if (window.Kakao && !window.Kakao.isInitialized()) { - window.Kakao.init(KAKAO_KEY); - } - }; - - document.head.appendChild(script); - } else if (!window.Kakao.isInitialized()) { - window.Kakao.init(KAKAO_KEY); - } - }, []); - - // URL 복사 기능 - const handleCopyUrl = async () => { - try { - await navigator.clipboard.writeText(shareUrl); - showToast('URL이 복사되었습니다.', 'success'); - onClose(); - } catch (err) { - console.error('URL 복사 실패:', err); - showToast('URL 복사에 실패했습니다.', 'delete'); - } - }; - - // 카카오톡 공유 기능 - const handleKakaoShare = () => { - if (window.Kakao) { - try { - window.Kakao.Share.sendScrap({ - requestUrl: shareUrl, - }); - } catch (err) { - console.error('카카오톡 공유 실패:', err); - showToast(true, '카카오톡 공유에 실패했습니다.'); - } - } else { - showToast(true, '카카오톡 SDK가 로드되지 않았습니다.'); - } - }; - - if (!isOpen) return null; - - return ( - - e.stopPropagation()}> - 공유하기 - - - 🗨️ - 카카오톡으로 공유하기 - - - 🔗 - URL 복사하기 - - - 닫기 - - - ); -} - diff --git a/src/components/rolling/emoji-display-list.jsx b/src/components/rolling/emoji-display-list.jsx new file mode 100644 index 0000000..3e1492f --- /dev/null +++ b/src/components/rolling/emoji-display-list.jsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { + RollingHeaderImojiIconContainer, + RollingHeaderImojiText, + RollingHeaderImojiIcon, +} from '@/styles/rolling-page-styles'; + +/** + * 이모지 표시 리스트 컴포넌트 + * 책임: 상위 N개의 이모지를 화면에 표시 + */ +export default function EmojiDisplayList({ emojis }) { + return ( + <> + {emojis.map((emojiData, index) => ( + + {emojiData.emoji} + {emojiData.count} + + ))} + + ); +} + diff --git a/src/components/rolling/emoji-dropdown.jsx b/src/components/rolling/emoji-dropdown.jsx new file mode 100644 index 0000000..b5bcc45 --- /dev/null +++ b/src/components/rolling/emoji-dropdown.jsx @@ -0,0 +1,108 @@ +import React from 'react'; +import styled from 'styled-components'; +import { colors } from '@/styles/colors'; +import media from '@/styles/media'; +import { font } from '@/styles/font'; +import { RollingHeaderArrowDown } from '@/styles/rolling-page-styles'; + +const EmojiDropdownContainer = styled.div` + position: relative; + display: inline-block; +`; + +const EmojiDropdownWrapper = styled.div` + position: fixed; + transform: translate(-80%, 10%); + z-index: 1000; + background: white; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + border: 1px solid ${colors.gray[300]}; + padding: 24px; + width: auto; + max-height: 300px; + overflow-y: auto; +`; + +const EmojiDropdownGrid = styled.div` + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; + ${media.medium` + grid-template-columns: repeat(3, 1fr); + `} + ${media.small` + grid-template-columns: repeat(3, 1fr); + `} +`; + +const EmojiDropdownItem = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: auto; + height: auto; + padding: 8px 12px; + text-align: center; + border-radius: 32px; + background: rgba(153, 153, 153, 1); + gap: 2px; + + ${media.small` + padding: 4px 8px; + `} +`; + +const EmojiDropdownIcon = styled.div``; + +const EmojiDropdownCount = styled.span` + ${font.regular16} + color: rgba(255, 255, 255, 1); +`; + +const Overlay = styled.div` + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: transparent; + z-index: 999; +`; + +/** + * 이모지 드롭다운 컴포넌트 + * 책임: 모든 이모지 목록을 드롭다운 형태로 표시 + */ +export default function EmojiDropdown({ + emojis, + isOpen, + onToggle, + onClose, + arrowDownIcon +}) { + return ( + + + {isOpen && ( + <> + + + + {emojis.map((emojiData, index) => ( + + {emojiData.emoji} + {emojiData.count} + + ))} + + + + )} + + ); +} + diff --git a/src/components/rolling/emoji-picker-component.jsx b/src/components/rolling/emoji-picker-component.jsx new file mode 100644 index 0000000..fc13037 --- /dev/null +++ b/src/components/rolling/emoji-picker-component.jsx @@ -0,0 +1,62 @@ +import React from 'react'; +import EmojiPicker from 'emoji-picker-react'; +import styled from 'styled-components'; +import { colors } from '@/styles/colors'; + +const EmojiPickerContainer = styled.div` + position: relative; + display: inline-block; +`; + +const EmojiPickerWrapper = styled.div` + position: fixed; + transform: translate(-60%, 2%); + z-index: 1000; + background: white; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + border: 1px solid ${colors.gray[300]}; +`; + +const Overlay = styled.div` + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: transparent; + z-index: 999; +`; + +/** + * 이모지 선택기 컴포넌트 + * 책임: 이모지 피커 UI 렌더링 및 이모지 선택 이벤트 처리 + */ +export default function EmojiPickerComponent({ isOpen, onClose, onEmojiSelect, children }) { + const handleEmojiClick = (emojiData) => { + onEmojiSelect(emojiData.emoji); + onClose(); + }; + + return ( + + {children} + {isOpen && ( + <> + + + + + + )} + + ); +} + diff --git a/src/components/rolling/header-action-buttons.jsx b/src/components/rolling/header-action-buttons.jsx new file mode 100644 index 0000000..b00531d --- /dev/null +++ b/src/components/rolling/header-action-buttons.jsx @@ -0,0 +1,55 @@ +import React from 'react'; +import styled from 'styled-components'; +import EmojiPickerComponent from './emoji-picker-component'; +import { + RollingHeaderImojiEditButtonContainer, + RollingHeaderImojiEditButton, + RollingHeaderImojiEditButtonIcon, + RollingHeaderImojiEditButtonText, + PerpendicularLineSecond, + RollingHeaderLinkShareButton, +} from '@/styles/rolling-page-styles'; + +const ShareButtonWrapper = styled.div` + position: relative; +`; + +/** + * 헤더 액션 버튼 컴포넌트 + * 책임: 이모지 추가 버튼과 공유 버튼 렌더링 + */ +export default function HeaderActionButtons({ + isEmojiPickerOpen, + onToggleEmojiPicker, + onCloseEmojiPicker, + onEmojiSelect, + onShareClick, + addEmojiIcon, + shareIcon, + shareModalComponent, // ShareModal 컴포넌트를 props로 받음 +}) { + return ( + + + + + 추가 + + + + + + {shareModalComponent} + + + ); +} + diff --git a/src/components/rolling/participant-section.jsx b/src/components/rolling/participant-section.jsx new file mode 100644 index 0000000..7d27ee6 --- /dev/null +++ b/src/components/rolling/participant-section.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { + RollingHeaderUserPeopleContainer, + RollingHeaderUserPeopleImages, +} from '@/styles/rolling-page-styles'; +import ProfileImageList from './profile-image-list'; +import ProfileOverflowBadge from './profile-overflow-badge'; +import ParticipantStats from './participant-stats'; +import useProfileImages from '@/hooks/use-profile-images'; + +/** + * 참여자 섹션 컴포넌트 + * 책임: 프로필 이미지와 참여자 통계를 조합하여 표시 + */ +export default function ParticipantSection({ profiles, maxVisible = 3 }) { + const { visibleProfiles, overflowCount, totalCount, hasOverflow } = + useProfileImages(profiles, maxVisible); + + return ( + + + {/* 보이는 프로필 이미지들 */} + + + {/* 오버플로우 뱃지 (+N) */} + {hasOverflow && } + + + {/* 참여자 통계 텍스트 */} + + + ); +} + diff --git a/src/components/rolling/participant-stats.jsx b/src/components/rolling/participant-stats.jsx new file mode 100644 index 0000000..e52c89c --- /dev/null +++ b/src/components/rolling/participant-stats.jsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { RollingHeaderUserPeopleState } from '@/styles/rolling-page-styles'; + +/** + * 참여자 통계 컴포넌트 + * 책임: 참여자 수 텍스트 표시 + */ +export default function ParticipantStats({ count }) { + if (count === 0) { + return ( + + 아직 작성한 사람이 없어요 + + ); + } + + return ( + + {count}명이 작성했어요! + + ); +} + diff --git a/src/components/rolling/profile-image-list.jsx b/src/components/rolling/profile-image-list.jsx new file mode 100644 index 0000000..22e5551 --- /dev/null +++ b/src/components/rolling/profile-image-list.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { RollingHeaderUserPeopleImage } from '@/styles/rolling-page-styles'; + +/** + * 프로필 이미지 리스트 컴포넌트 + * 책임: 프로필 이미지들을 렌더링 (래퍼 없이 순수 이미지만) + */ +export default function ProfileImageList({ profiles }) { + if (!profiles || profiles.length === 0) { + return null; + } + + return ( + <> + {profiles.map((profile, index) => ( + + ))} + + ); +} + diff --git a/src/components/rolling/profile-overflow-badge.jsx b/src/components/rolling/profile-overflow-badge.jsx new file mode 100644 index 0000000..2fe5249 --- /dev/null +++ b/src/components/rolling/profile-overflow-badge.jsx @@ -0,0 +1,32 @@ +import React from 'react'; +import styled from 'styled-components'; +import { colors } from '@/styles/colors'; +import { font } from '@/styles/font'; + +const OverflowBadge = styled.div` + width: 28px; + height: 28px; + border-radius: 140px; + border: 1.4px solid ${colors.gray[900]}; + background: ${colors.gray[200]}; + display: flex; + align-items: center; + justify-content: center; + position: relative; + margin-left: -10px; + ${font.regular12} + color: ${colors.gray[700]}; +`; + +/** + * 프로필 오버플로우 뱃지 컴포넌트 + * 책임: 추가 인원 수를 표시 (+N) + */ +export default function ProfileOverflowBadge({ count }) { + if (count <= 0) { + return null; + } + + return +{count}; +} + diff --git a/src/components/rolling/share-button-group.jsx b/src/components/rolling/share-button-group.jsx new file mode 100644 index 0000000..dddc833 --- /dev/null +++ b/src/components/rolling/share-button-group.jsx @@ -0,0 +1,70 @@ +import React from 'react'; +import styled from 'styled-components'; +import { colors } from '@/styles/colors'; +import { font } from '@/styles/font'; + +const ButtonGroup = styled.div` + position: absolute; + top: calc(100% + 8px); + right: 0; + width: 140px; + height: auto; + display: flex; + flex-direction: column; + background: white; + border-radius: 8px; + border: 1px solid ${colors.gray[300]}; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 1001; + padding: 10px 0px; +`; + +const ShareButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + background: transparent; + width: 100%; + height: 50px; + border: none; + cursor: pointer; + transition: all 0.2s; + ${font.regular16} + color: ${colors.gray[900]}; + + &:hover { + background: ${colors.gray[200]}; + border-color: ${colors.gray[400]}; + + } + + &:active { + transform: scale(0.98); + } +`; + +const KakaoButton = styled(ShareButton)` + background: #fee500; + border-color: #fee500; + color: #000000; + + &:hover { + background: #fdd835; + border-color: #fdd835; + } +`; + +/** + * 공유 버튼 그룹 컴포넌트 + * 책임: 공유 방법별 버튼 UI 렌더링 + */ +export default function ShareButtonGroup({ onKakaoShare, onCopyUrl }) { + return ( + + 카카오톡 공유 + URL 복사 + + ); +} + diff --git a/src/components/rolling/share-modal.jsx b/src/components/rolling/share-modal.jsx new file mode 100644 index 0000000..a1a18dc --- /dev/null +++ b/src/components/rolling/share-modal.jsx @@ -0,0 +1,55 @@ +import React from 'react'; +import styled from 'styled-components'; + +import ShareButtonGroup from './share-button-group'; +import useKakaoSdk from '@/hooks/use-kakao-sdk'; +import useShareActions from '@/hooks/use-share-actions'; + +const Overlay = styled.div` + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: transparent; + z-index: 999; +`; + +/** + * 공유 모달 컴포넌트 + * 책임: 공유 모달 UI 및 공유 액션 연결 + */ +export default function ShareModal({ isOpen, onClose, shareUrl }) { + // 카카오 SDK 초기화 + useKakaoSdk(); + + // 공유 기능 훅 + const { copyToClipboard, shareToKakao } = useShareActions(); + + // URL 복사 핸들러 + const handleCopyUrl = async () => { + const success = await copyToClipboard(shareUrl); + if (success) { + onClose(); + } + }; + + // 카카오톡 공유 핸들러 + const handleKakaoShare = () => { + shareToKakao(shareUrl); + }; + + if (!isOpen) return null; + + return ( + <> + + e.stopPropagation()} + onKakaoShare={handleKakaoShare} + onCopyUrl={handleCopyUrl} + /> + + ); +} + diff --git a/src/hooks/use-emoji-manager.js b/src/hooks/use-emoji-manager.js new file mode 100644 index 0000000..c47018f --- /dev/null +++ b/src/hooks/use-emoji-manager.js @@ -0,0 +1,41 @@ +import { useState } from 'react'; + +/** + * 이모지 관리 커스텀 훅 + * 책임: 이모지 상태 관리 및 비즈니스 로직 처리 + */ +export default function useEmojiManager(initialEmojis = []) { + const [selectedEmojis, setSelectedEmojis] = useState(initialEmojis); + + const handleEmojiSelect = (emoji) => { + const existingEmojiIndex = selectedEmojis.findIndex(item => item.emoji === emoji); + + if (existingEmojiIndex !== -1) { + // 이미 존재하는 이모지면 카운트 증가 + const updatedEmojis = [...selectedEmojis]; + updatedEmojis[existingEmojiIndex].count += 1; + setSelectedEmojis(updatedEmojis); + } else { + // 새로운 이모지면 추가 + setSelectedEmojis([...selectedEmojis, { emoji, count: 1 }]); + } + }; + + // 카운트 순으로 정렬 + const getSortedEmojis = () => { + return [...selectedEmojis].sort((a, b) => b.count - a.count); + }; + + // 상위 N개 추출 + const getTopEmojis = (count) => { + return getSortedEmojis().slice(0, count); + }; + + return { + selectedEmojis, + handleEmojiSelect, + getSortedEmojis, + getTopEmojis, + }; +} + diff --git a/src/hooks/use-kakao-sdk.js b/src/hooks/use-kakao-sdk.js new file mode 100644 index 0000000..54f240c --- /dev/null +++ b/src/hooks/use-kakao-sdk.js @@ -0,0 +1,41 @@ +import { useEffect } from 'react'; + +const KAKAO_KEY = import.meta.env.VITE_KAKAO_KEY; + +/** + * 카카오 SDK 초기화 커스텀 훅 + * 책임: 카카오 SDK 스크립트 로드 및 초기화 + */ +export default function useKakaoSdk() { + useEffect(() => { + // 이미 SDK가 로드되어 있으면 초기화만 수행 + if (window.Kakao) { + if (!window.Kakao.isInitialized()) { + window.Kakao.init(KAKAO_KEY); + } + return; + } + + // SDK 스크립트 동적 로드 + const script = document.createElement('script'); + script.src = 'https://t1.kakaocdn.net/kakao_js_sdk/2.7.7/kakao.min.js'; + script.integrity = 'sha384-tJkjbtDbvoxO+diRuDtwRO9JXR7pjWnfjfRn5ePUpl7e7RJCxKCwwnfqUAdXh53p'; + script.crossOrigin = 'anonymous'; + script.async = true; + + script.onload = () => { + if (window.Kakao && !window.Kakao.isInitialized()) { + window.Kakao.init(KAKAO_KEY); + } + }; + + document.head.appendChild(script); + + // 클린업 함수는 필요시 추가 (일반적으로는 SDK를 제거하지 않음) + }, []); + + return { + isKakaoReady: window.Kakao?.isInitialized() || false, + }; +} + diff --git a/src/hooks/use-profile-images.js b/src/hooks/use-profile-images.js new file mode 100644 index 0000000..8e83833 --- /dev/null +++ b/src/hooks/use-profile-images.js @@ -0,0 +1,36 @@ +import { useMemo } from 'react'; + +/** + * 프로필 이미지 데이터 처리 커스텀 훅 + * 책임: 프로필 이미지 데이터 가공 및 오버플로우 계산 + */ +export default function useProfileImages(profiles, maxVisible = 3) { + const processedData = useMemo(() => { + if (!profiles || profiles.length === 0) { + return { + visibleProfiles: [], + overflowCount: 0, + totalCount: 0, + hasOverflow: false, + }; + } + + const totalCount = profiles.length; + const hasOverflow = totalCount > maxVisible; + + // 오버플로우가 있으면 마지막 자리는 +N 표시용으로 비움 + const visibleCount = hasOverflow ? maxVisible - 1 : maxVisible; + const visibleProfiles = profiles.slice(0, visibleCount); + const overflowCount = hasOverflow ? totalCount - visibleCount : 0; + + return { + visibleProfiles, + overflowCount, + totalCount, + hasOverflow, + }; + }, [profiles, maxVisible]); + + return processedData; +} + diff --git a/src/hooks/use-share-actions.js b/src/hooks/use-share-actions.js new file mode 100644 index 0000000..fb57522 --- /dev/null +++ b/src/hooks/use-share-actions.js @@ -0,0 +1,52 @@ +import { useCallback } from 'react'; +import useToast from '@/hooks/use-toast'; + +/** + * 공유 기능 커스텀 훅 + * 책임: URL 복사 및 카카오톡 공유 비즈니스 로직 처리 + */ +export default function useShareActions() { + const { showToast } = useToast(); + + /** + * URL을 클립보드에 복사 + */ + const copyToClipboard = useCallback(async (url) => { + try { + await navigator.clipboard.writeText(url); + showToast('URL이 복사되었습니다.', 'success'); + return true; + } catch (err) { + console.error('URL 복사 실패:', err); + showToast('URL 복사에 실패했습니다.', 'delete'); + return false; + } + }, [showToast]); + + /** + * 카카오톡으로 공유 + */ + const shareToKakao = useCallback((url) => { + if (!window.Kakao) { + showToast('카카오톡 SDK가 로드되지 않았습니다.', 'delete'); + return false; + } + + try { + window.Kakao.Share.sendScrap({ + requestUrl: url, + }); + return true; + } catch (err) { + console.error('카카오톡 공유 실패:', err); + showToast('카카오톡 공유에 실패했습니다.', 'delete'); + return false; + } + }, [showToast]); + + return { + copyToClipboard, + shareToKakao, + }; +} + diff --git a/src/pages/rolling-page-head.jsx b/src/pages/rolling-page-head.jsx index 707c2f8..f165398 100644 --- a/src/pages/rolling-page-head.jsx +++ b/src/pages/rolling-page-head.jsx @@ -1,146 +1,27 @@ import React, { useState } from 'react'; -import EmojiPicker from 'emoji-picker-react'; -import styled from 'styled-components'; -import { colors } from '@/styles/colors'; -import media from '@/styles/media'; -import { font } from '@/styles/font'; -import ShareModal from '@/components/common/share-modal'; -import { - RollingHeaderImojiContainer, - RollingHeaderImojiIconContainer, - RollingHeaderImojiText, - RollingHeaderImojiIcon, - RollingHeaderImojiEditButtonContainer, - RollingHeaderImojiEditButton, - RollingHeaderImojiEditButtonIcon, - RollingHeaderImojiEditButtonText, - RollingHeaderArrowDown, - PerpendicularLineSecond, - RollingHeaderLinkShareButton, -} from '@/styles/rolling-page-styles'; - -const EmojiPickerContainer = styled.div` - position: relative; - display: inline-block; -`; - -const EmojiPickerWrapper = styled.div` - position: fixed; - transform: translate(-60%, 2%); - z-index: 1000; - background: white; - border-radius: 8px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - border: 1px solid ${colors.gray[300]}; - -`; - -const Overlay = styled.div` - position: fixed; - top: 0; - left: 0; - - background: transparent; - z-index: 999; -`; - -// 이모지 드롭다운 관련 스타일 -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) -`; - -// 이모지 피커 컴포넌트 -function EmojiPickerComponent({ isOpen, onClose, onEmojiSelect, children }) { - const handleEmojiClick = (emojiData) => { - onEmojiSelect(emojiData.emoji); - onClose(); - }; - - return ( - - {children} - {isOpen && ( - <> - - - - - - )} - - ); -} - -// 롤링 페이지 헤더 컴포넌트 +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 [selectedEmojis, setSelectedEmojis] = useState([ + + // 이모지 상태 및 로직 관리 + const initialEmojis = [ { emoji: '😘', count: 12 }, { emoji: '😍', count: 8 }, { emoji: '👍', count: 15 }, @@ -148,22 +29,11 @@ export default function RollingPageHeader({ { emoji: '❤️', count: 20 }, { emoji: '😂', count: 3 }, { emoji: '🔥', count: 7 } - ]); + ]; - const handleEmojiSelect = (emoji) => { - const existingEmojiIndex = selectedEmojis.findIndex(item => item.emoji === emoji); - - if (existingEmojiIndex !== -1) { - // 이미 존재하는 이모지면 카운트 증가 - const updatedEmojis = [...selectedEmojis]; - updatedEmojis[existingEmojiIndex].count += 1; - setSelectedEmojis(updatedEmojis); - } else { - // 새로운 이모지면 추가 - setSelectedEmojis([...selectedEmojis, { emoji, count: 1 }]); - } - }; + const { handleEmojiSelect, getSortedEmojis, getTopEmojis } = useEmojiManager(initialEmojis); + // 이모지 피커 핸들러 const toggleEmojiPicker = () => { setIsEmojiPickerOpen(!isEmojiPickerOpen); }; @@ -172,6 +42,7 @@ export default function RollingPageHeader({ setIsEmojiPickerOpen(false); }; + // 이모지 드롭다운 핸들러 const toggleEmojiDropdown = () => { setIsEmojiDropdownOpen(!isEmojiDropdownOpen); }; @@ -180,6 +51,7 @@ export default function RollingPageHeader({ setIsEmojiDropdownOpen(false); }; + // 공유 모달 핸들러 const openShareModal = () => { setIsShareModalOpen(true); }; @@ -188,70 +60,46 @@ export default function RollingPageHeader({ setIsShareModalOpen(false); }; - // 카운트 순으로 정렬하여 상위 3개만 추출 - const sortedEmojis = [...selectedEmojis].sort((a, b) => b.count - a.count); - const topThreeEmojis = sortedEmojis.slice(0, 3); - const hasMoreEmojis = selectedEmojis.length > 3; + // 정렬된 이모지 및 상위 3개 추출 + const sortedEmojis = getSortedEmojis(); + const topThreeEmojis = getTopEmojis(3); + const hasMoreEmojis = sortedEmojis.length > 3; - // 현재 페이지 URL 가져오기 + // 현재 페이지 URL const currentUrl = window.location.href; return ( - {topThreeEmojis.map((emojiData, index) => ( - - {emojiData.emoji} - {emojiData.count} - - ))} + {/* 상위 3개 이모지 표시 */} + + {/* 더 많은 이모지가 있을 경우 드롭다운 */} {hasMoreEmojis && ( - - - {isEmojiDropdownOpen && ( - <> - - - - {sortedEmojis.map((emojiData, index) => ( - - {emojiData.emoji} - {emojiData.count} - - ))} - - - - )} - - )} - - - - - - 추가 - - - - - + )} - + } /> ); diff --git a/src/pages/rolling-page.jsx b/src/pages/rolling-page.jsx index 749a8c8..5524288 100644 --- a/src/pages/rolling-page.jsx +++ b/src/pages/rolling-page.jsx @@ -1,23 +1,27 @@ -import React from 'react'; +import React, { useState } from 'react'; import { RollingHeaderContainer, RollingHeaderUserInfo, RollingHeaderRightContainer, - RollingHeaderUserPeopleContainer, - RollingHeaderUserPeopleImages, - RollingHeaderUserPeopleImage, - RollingHeaderUserDefaultImage, - RollingHeaderUserPeopleState, 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"; - 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 ( <> @@ -26,35 +30,23 @@ export default function RollingPage() { - - {/* //여기에서 함수를 불러와서 처리해야함 */} - - - - - - - - - 23명이 작성 했어요! - - - + {/* 참여자 프로필 섹션 */} + + + {/* 이모지 및 공유 헤더 */} - - + - + {/* 롤링 페이퍼 컨텐츠가 들어갈 영역 */} - ); } diff --git a/src/styles/rolling-page-styles.js b/src/styles/rolling-page-styles.js index 34ccb73..f29f391 100644 --- a/src/styles/rolling-page-styles.js +++ b/src/styles/rolling-page-styles.js @@ -22,8 +22,7 @@ export const RollingHeaderContainer = styled.div` height: 68px; margin: 0 auto; padding: 13px 20px; - overflow-x: auto; - overflow-y: hidden; + gap: 20px; `} @@ -32,8 +31,7 @@ export const RollingHeaderContainer = styled.div` height: 68px; margin: 0; padding: 13px 20px; - overflow-x: auto; - overflow-y: hidden; + gap: 10px; `} @@ -123,9 +121,11 @@ export const RollingHeaderUserPeopleContainer = styled.div` //유저 이미지 프로필 사진들 export const RollingHeaderUserPeopleImages = styled.div` + display: flex; width: 76px; height: 28px; position: relative; + cursor: pointer; ${media.medium` @@ -143,7 +143,7 @@ export const RollingHeaderUserPeopleImage = styled.img` width: 28px; height: 28px; border-radius: 140px; - border: 1.4px solid #000; + border: 1.4px solid #fff; position: relative; margin-left: -10px; `; From 6cd31c81a78f2806c2c8c41dccf8e286657044d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EC=9B=90?= Date: Wed, 12 Nov 2025 11:17:27 +0900 Subject: [PATCH 13/20] =?UTF-8?q?Feat:=20=EB=A6=AC=EB=B7=B0=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F,=20=EC=B9=B4=EB=93=9C=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.jsx | 20 ++- src/components/rolling/card-contents.jsx | 59 +++++++ src/hooks/use-cards.js | 36 +++++ src/pages/rolling-page-edit.jsx | 58 +++++++ src/pages/rolling-page.jsx | 6 +- src/styles/rolling-page-styles.js | 190 ++++++++++++++++++++++- 6 files changed, 349 insertions(+), 20 deletions(-) create mode 100644 src/components/rolling/card-contents.jsx create mode 100644 src/hooks/use-cards.js create mode 100644 src/pages/rolling-page-edit.jsx diff --git a/src/App.jsx b/src/App.jsx index 6d3af8e..56f5e51 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -13,21 +13,19 @@ function App() { return ( <> - - - }> - } /> - {/* } /> + + }> + } /> + {/* } /> } /> } /> } /> } /> */} - } /> - } /> - } /> - - - + } /> + } /> + } /> + +
); } diff --git a/src/components/rolling/card-contents.jsx b/src/components/rolling/card-contents.jsx new file mode 100644 index 0000000..4c1336b --- /dev/null +++ b/src/components/rolling/card-contents.jsx @@ -0,0 +1,59 @@ +import { + CardContainer, + Card, + CardEditButton, + CardContentContainer, + CardContentStatus, + CardContentStatusContainer, + CardContentStatusProfileImage, + CardContentStatusProfileName, + CardContentStatusRelationship, + CardContentText, + CardContentDate, + CardContentStatusProfileContainer, + CardContentDeleteButton, +} from "@/styles/rolling-page-styles"; +import { useState } from "react"; +import useCards from "@/hooks/use-cards"; + + +export default function CardContents({ maxVisible = 6 }) { + const [cards] = useState([ + { id: 1, name: '김철수', profileImageURL: 'https://via.placeholder.com/28', relationship: 'friend' }, + { id: 2, name: '이영희', profileImageURL: 'https://via.placeholder.com/28', relationship: 'family' }, + { id: 3, name: '박민수', profileImageURL: 'https://via.placeholder.com/28', relationship: 'colleague' }, + { id: 4, name: '최영희', profileImageURL: 'https://via.placeholder.com/28', relationship: 'acquaintance' }, + { id: 5, name: 'dsadsa', profileImageURL: 'https://via.placeholder.com/28', relationship: 'friend' }, + { id: 6, name: 'qweqwe', profileImageURL: 'https://via.placeholder.com/28', relationship: 'family' }, + { id: 7, name: 'zxczxc', profileImageURL: 'https://via.placeholder.com/28', relationship: 'colleague' }, + { id: 8, name: 'm,nmnb', profileImageURL: 'https://via.placeholder.com/28', relationship: 'acquaintance' } + ]); + const { visibleCards } = useCards(cards, maxVisible); + + return ( + <> + + + {visibleCards.map((card) => ( + + + + + + + From. {card.name} + {card.relationship} + + + + + sdasdsa + 2025.11.12 + + + + ))} + + + ); +} \ No newline at end of file diff --git a/src/hooks/use-cards.js b/src/hooks/use-cards.js new file mode 100644 index 0000000..dcf0bfc --- /dev/null +++ b/src/hooks/use-cards.js @@ -0,0 +1,36 @@ +import { useMemo } from 'react'; + + +/** + * 카드 섹션 컴포넌트 + * 책임: 카드 데이터를 처리하고 표시 + */ +export default function useCards(cards, maxVisible = 6) { + const processedData = useMemo(() => { + if (!cards || cards.length === 0) { + return { + visibleCards: [], + overflowCount: 0, + totalCount: 0, + hasOverflow: false, + }; + } + + const totalCount = cards.length; + const hasOverflow = totalCount > maxVisible; + + const visibleCount = hasOverflow ? maxVisible - 1 : maxVisible; + const visibleCards = cards.slice(0, visibleCount); + const overflowCount = hasOverflow ? totalCount - visibleCount : 0; + + return { + visibleCards, + overflowCount, + totalCount, + hasOverflow, + }; + }, [cards, maxVisible]); + + return processedData; +} + 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.jsx b/src/pages/rolling-page.jsx index 5524288..9c43cba 100644 --- a/src/pages/rolling-page.jsx +++ b/src/pages/rolling-page.jsx @@ -5,12 +5,15 @@ import { 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에서 받아올 데이터 @@ -22,6 +25,7 @@ export default function RollingPage() { ]); + return ( <> @@ -45,7 +49,7 @@ export default function RollingPage() { - {/* 롤링 페이퍼 컨텐츠가 들어갈 영역 */} + ); diff --git a/src/styles/rolling-page-styles.js b/src/styles/rolling-page-styles.js index f29f391..d2162db 100644 --- a/src/styles/rolling-page-styles.js +++ b/src/styles/rolling-page-styles.js @@ -3,6 +3,8 @@ import { colors } from "@/styles/colors"; import { font } from "@/styles/font"; import ShareIcon from "@/assets/icons/share.svg"; import media from "@/styles/media"; +import EditIcon from "@/assets/icons/plus.svg"; +import DeleteIcon from "@/assets/icons/deleted.svg"; //최상단헤더 컨테이너 @@ -278,13 +280,6 @@ export const RollingHeaderArrowDown = styled.img` -export const RollingPageContainer = styled.div` -background-color: ${colors.blue[100]}; -width: 100%; -margin: 0 auto; -padding: 20px; -height: 100vh; -`; @@ -303,4 +298,183 @@ export const PerpendicularLineFirst = styled(PerpendicularLine)` `} `; -export const PerpendicularLineSecond = styled(PerpendicularLine)``; \ No newline at end of file +export const PerpendicularLineSecond = styled(PerpendicularLine)``; + + + +export const RollingPageContainer = styled.div` +display: flex; +justify-content: center; +align-items: center; +background-color: ${colors.blue[100]}; +width: 100%; +margin: 0 auto; +padding: 20px; + +`; + + + +export const CardContainer = styled.div` + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-template-rows: repeat(2, 1fr); + + gap: 20px; + ${media.medium` + grid-template-columns: repeat(2, 1fr); + grid-template-rows: repeat(3, 1fr); + `} + ${media.small` + grid-template-columns: repeat(1, 1fr); + grid-template-rows: repeat(6, 1fr); + `} +`; + +export const Card = styled.div` + display: flex; + justify-content: center; + align-items: center; + width: 384px; + height: 280px; + border-radius: 16px; + background-color: #fff; + position: relative; +`; + +export const CardEditButton = styled.button` + width: 56px; + height: 56px; + background-image: url("${EditIcon}"); + background-color: ${colors.gray[500]}; + border-radius: 100px; + border: none; + padding: 20px; + background-size: 24px 24px; + background-repeat: no-repeat; + background-position: center; + cursor: pointer; + &:hover { + background-color: ${colors.gray[400]}; + color: ${colors.gray[100]}; + } +`; + +export const CardContentContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + width: 100%; + height: 100%; + padding: 16px 24px; +`; + +export const CardContentStatus = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + height: auto; + gap: 14px; + border-bottom: 1px solid ${colors.gray[200]}; + padding-bottom: 16px; +`; + +export const CardContentStatusContainer = styled.div` + display: flex; + align-items: center; + gap: 14px; +`; + +export const CardContentStatusProfileContainer = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; +`; + +export const CardContentFrom = styled.div` + + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; +`; + +export const CardContentStatusProfileImage = styled.img` + width: 56px; + height: 56px; + border-radius: 100px; + border: 1px solid ${colors.gray[300]}; +`; + +export const CardContentStatusProfileName = styled.div` + ${font.regular16} + color: ${colors.gray[900]}; +`; + +const relationshipColors = { + friend: colors.blue[100], + family: colors.green[100], + colleague: colors.purple[100], + acquaintance: colors.beige[100], +}; + +const relationshipTextColors = { + friend: colors.blue[500], + family: colors.green[500], + colleague: colors.purple[600], + acquaintance: colors.beige[500], +}; + +// const relationshipLabels = { +// friend: '친구', +// family: '가족', +// colleague: '동료', +// acquaintance: '지인', +// }; + +export const CardContentStatusRelationship = styled.div` + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 8px; + height: 20px; + border-radius: 4px; + ${font.regular14} + color: ${props => relationshipTextColors[props.$relationship] || colors.gray[500]}; + + background-color: ${props => relationshipColors[props.$relationship] || colors.gray[500]}; +`; + +export const CardContentText = styled.div` + width: 100%; + height: 100%; + ${font.regular16} + color: ${colors.gray[600]}; + padding-top: 16px; + cursor: pointer; +`; + +export const CardContentDate = styled.div` + ${font.regular12} + color: ${colors.gray[400]}; +`; + +export const CardContentDeleteButton = styled.div` + width: 40px; + height: 40px; + background-image: url("${DeleteIcon}"); + border-radius: 6px; + border: 1px solid ${colors.gray[300]}; + padding: 20px; + background-size: 24px 24px; + background-repeat: no-repeat; + background-position: center; + cursor: pointer; + &:hover { + background-color: ${colors.gray[200]}; + color: ${colors.gray[100]}; + } +`; \ No newline at end of file From e2000a86c697945e672186bf7940eeaee51c1703 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EC=9B=90?= Date: Wed, 12 Nov 2025 11:53:27 +0900 Subject: [PATCH 14/20] =?UTF-8?q?Chore:=20PR=20=EB=B9=8C=EB=93=9C=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=EC=9E=AC?= =?UTF-8?q?=ED=91=B8=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.jsx | 13 ++++++------- src/hooks/use-kakao-sdk.js | 2 +- src/hooks/use-share-actions.js | 11 +++++------ 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 56f5e51..ba3d4cb 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,6 +1,5 @@ import { Routes, Route } from "react-router"; import { GlobalStyle } from "@/styles/global-style"; -import ToastProvider from "@/contexts/toast-context"; import GlobalLayout from "@/components/common/global-layout"; import TestPage from "@/pages/test-page"; import MessagePage from "@/pages/message-page"; @@ -8,6 +7,7 @@ import MainPage from "@/pages/main-page"; import PostPage from "@/pages/post-page"; import TempPage from "@/pages/temp-page"; import ToastTestPage from "@/pages/toast-test-page"; +import RollingPage from "@/pages/rolling-page"; function App() { return ( @@ -16,13 +16,12 @@ function App() { }> } /> - {/* } /> - } /> - } /> - } /> - } /> */} - } /> + } /> + {/* } /> */} } /> + } /> + } /> + } /> } /> diff --git a/src/hooks/use-kakao-sdk.js b/src/hooks/use-kakao-sdk.js index 54f240c..7a8ac58 100644 --- a/src/hooks/use-kakao-sdk.js +++ b/src/hooks/use-kakao-sdk.js @@ -7,6 +7,7 @@ const KAKAO_KEY = import.meta.env.VITE_KAKAO_KEY; * 책임: 카카오 SDK 스크립트 로드 및 초기화 */ export default function useKakaoSdk() { + useEffect(() => { // 이미 SDK가 로드되어 있으면 초기화만 수행 if (window.Kakao) { @@ -31,7 +32,6 @@ export default function useKakaoSdk() { document.head.appendChild(script); - // 클린업 함수는 필요시 추가 (일반적으로는 SDK를 제거하지 않음) }, []); return { diff --git a/src/hooks/use-share-actions.js b/src/hooks/use-share-actions.js index fb57522..03bd9ea 100644 --- a/src/hooks/use-share-actions.js +++ b/src/hooks/use-share-actions.js @@ -1,12 +1,12 @@ import { useCallback } from 'react'; -import useToast from '@/hooks/use-toast'; +import { useToast } from '@/hooks/use-toast'; /** * 공유 기능 커스텀 훅 * 책임: URL 복사 및 카카오톡 공유 비즈니스 로직 처리 */ export default function useShareActions() { - const { showToast } = useToast(); + const showToast = useToast(); /** * URL을 클립보드에 복사 @@ -14,11 +14,10 @@ export default function useShareActions() { const copyToClipboard = useCallback(async (url) => { try { await navigator.clipboard.writeText(url); - showToast('URL이 복사되었습니다.', 'success'); + showToast.success('URL이 복사되었습니다.'); return true; } catch (err) { console.error('URL 복사 실패:', err); - showToast('URL 복사에 실패했습니다.', 'delete'); return false; } }, [showToast]); @@ -28,7 +27,6 @@ export default function useShareActions() { */ const shareToKakao = useCallback((url) => { if (!window.Kakao) { - showToast('카카오톡 SDK가 로드되지 않았습니다.', 'delete'); return false; } @@ -36,10 +34,11 @@ export default function useShareActions() { window.Kakao.Share.sendScrap({ requestUrl: url, }); + showToast.success('카카오톡으로 공유되었습니다.'); + return true; } catch (err) { console.error('카카오톡 공유 실패:', err); - showToast('카카오톡 공유에 실패했습니다.', 'delete'); return false; } }, [showToast]); From 7589a095495bc95882e6aa1979dfc0c2710ce69c Mon Sep 17 00:00:00 2001 From: BZzzzi Date: Wed, 12 Nov 2025 14:37:22 +0900 Subject: [PATCH 15/20] =?UTF-8?q?Refactor:=20ToastProvider=20=EC=84=B1?= =?UTF-8?q?=EB=8A=A5=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hideToast, showToast 함수를 useCallback으로 메모이제이션하여 불필요한 리렌더링 방지 - 닫기 애니메이션 타이밍 3000ms에서 300ms로 조정 - 자동 닫기 타이밍을 3000ms에서 5000ms로 조정 --- src/components/common/toast-provider.jsx | 41 +++++++++++++----------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/src/components/common/toast-provider.jsx b/src/components/common/toast-provider.jsx index 662e0e2..a54e466 100644 --- a/src/components/common/toast-provider.jsx +++ b/src/components/common/toast-provider.jsx @@ -1,4 +1,4 @@ -import { useState, useRef } from "react"; +import { useState, useRef, useCallback } from "react"; import { createPortal } from "react-dom"; import Toast from "@/components/common/toast"; import { ToastContext } from "@/hooks/use-toast"; @@ -9,7 +9,7 @@ export function ToastProvider({ children }) { const autoCloseTimerRef = useRef(null); const closeAnimTimerRef = useRef(null); - const hideToast = () => { + const hideToast = useCallback(() => { if (autoCloseTimerRef.current) { clearTimeout(autoCloseTimerRef.current); autoCloseTimerRef.current = null; @@ -28,26 +28,29 @@ export function ToastProvider({ children }) { closeAnimTimerRef.current = setTimeout(() => { setToast(null); closeAnimTimerRef.current = null; - }, 3000); - }; + }, 300); + }, []); - const showToast = (message, type = "success") => { - if (autoCloseTimerRef.current) clearTimeout(autoCloseTimerRef.current); - if (closeAnimTimerRef.current) { - clearTimeout(closeAnimTimerRef.current); - } + const showToast = useCallback( + (message, type = "success") => { + if (autoCloseTimerRef.current) clearTimeout(autoCloseTimerRef.current); + if (closeAnimTimerRef.current) { + clearTimeout(closeAnimTimerRef.current); + } - setToast({ - message, - type, - key: Date.now(), - isClosing: false, - }); + setToast({ + message, + type, + key: Date.now(), + isClosing: false, + }); - autoCloseTimerRef.current = setTimeout(() => { - hideToast(); - }, 3000); - }; + autoCloseTimerRef.current = setTimeout(() => { + hideToast(); + }, 5000); + }, + [hideToast] + ); const contextValue = { success: (message) => showToast(message, "success"), From 8333f6d1f5a4a93f98a52d9ee605b7ec88fbddd9 Mon Sep 17 00:00:00 2001 From: BZzzzi Date: Wed, 12 Nov 2025 14:38:30 +0900 Subject: [PATCH 16/20] =?UTF-8?q?Feat:=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /list 경로에 ListPage 컴포넌트 라우팅 설정 - App.jsx에 ListPage import 및 Route 추가 - list-page.jsx 기본 컴포넌트 생성 --- src/App.jsx | 1 + src/pages/list-page.jsx | 3 +++ 2 files changed, 4 insertions(+) create mode 100644 src/pages/list-page.jsx diff --git a/src/App.jsx b/src/App.jsx index ba3d4cb..9f4a9ca 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -4,6 +4,7 @@ import GlobalLayout from "@/components/common/global-layout"; import TestPage from "@/pages/test-page"; import MessagePage from "@/pages/message-page"; import MainPage from "@/pages/main-page"; +import ListPage from "@/pages/list-page"; import PostPage from "@/pages/post-page"; import TempPage from "@/pages/temp-page"; import ToastTestPage from "@/pages/toast-test-page"; diff --git a/src/pages/list-page.jsx b/src/pages/list-page.jsx new file mode 100644 index 0000000..ca3e299 --- /dev/null +++ b/src/pages/list-page.jsx @@ -0,0 +1,3 @@ +export default function ListPage() { + return <>Hello; +} From 032b2fd29d4f8e1d860815b4d414321cca86e297 Mon Sep 17 00:00:00 2001 From: BZzzzi Date: Wed, 12 Nov 2025 14:53:12 +0900 Subject: [PATCH 17/20] =?UTF-8?q?Refactor:=20=EC=9E=84=EC=8B=9C=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=9D=BC=EC=9A=B0=ED=8A=B8=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.jsx | 2 +- src/pages/temp-page.jsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 9f4a9ca..57d237c 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -18,7 +18,7 @@ function App() { }> } /> } /> - {/* } /> */} + } /> } /> } /> } /> diff --git a/src/pages/temp-page.jsx b/src/pages/temp-page.jsx index cb732a2..9fd7121 100644 --- a/src/pages/temp-page.jsx +++ b/src/pages/temp-page.jsx @@ -24,7 +24,7 @@ export default function TempPage() { 리스트 페이지 - + 롤페 페이지 From e06ecc38bdff0de14a0e28b0d691ff4f505f7bb9 Mon Sep 17 00:00:00 2001 From: BZzzzi Date: Wed, 12 Nov 2025 16:43:08 +0900 Subject: [PATCH 18/20] =?UTF-8?q?Refactor:=20=EC=9E=84=EC=8B=9C=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=B0=8F=20=ED=97=A4=EB=8D=94=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EB=A7=81=ED=81=AC=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=9E=AC=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.jsx | 6 +++--- src/components/common/global-layout.jsx | 2 +- src/components/common/header.jsx | 2 +- src/contexts/toast-context-state.jsx | 5 ----- src/pages/temp-page.jsx | 6 +++--- 5 files changed, 8 insertions(+), 13 deletions(-) delete mode 100644 src/contexts/toast-context-state.jsx diff --git a/src/App.jsx b/src/App.jsx index 57d237c..928e238 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -21,9 +21,9 @@ function App() { } /> } /> } /> - } /> - } /> - } /> + } /> + } /> + } /> diff --git a/src/components/common/global-layout.jsx b/src/components/common/global-layout.jsx index db2b1ed..143890f 100644 --- a/src/components/common/global-layout.jsx +++ b/src/components/common/global-layout.jsx @@ -1,7 +1,7 @@ import { Outlet, useLocation } from "react-router"; import Header from "@/components/common/header"; -const PAGES_WITH_BUTTON = ["main-page", "list-page"]; +const PAGES_WITH_BUTTON = ["main", "list"]; export default function GlobalLayout() { const location = useLocation(); diff --git a/src/components/common/header.jsx b/src/components/common/header.jsx index 44210f8..ccb1485 100644 --- a/src/components/common/header.jsx +++ b/src/components/common/header.jsx @@ -48,7 +48,7 @@ export default function Header({ showButton }) { {showButton && ( - + diff --git a/src/contexts/toast-context-state.jsx b/src/contexts/toast-context-state.jsx deleted file mode 100644 index a5eb1e9..0000000 --- a/src/contexts/toast-context-state.jsx +++ /dev/null @@ -1,5 +0,0 @@ -import React, { createContext } from 'react'; - -export const ToastContext = createContext(); - - diff --git a/src/pages/temp-page.jsx b/src/pages/temp-page.jsx index 9fd7121..a605c6a 100644 --- a/src/pages/temp-page.jsx +++ b/src/pages/temp-page.jsx @@ -30,15 +30,15 @@ export default function TempPage() { 롤페 생성 페이지 - + 롤페 메시지 페이지 - + 테스트 페이지 - + toast 테스트 페이지 From 6735707755f1bb685a46f03f7d216403f72b0bc5 Mon Sep 17 00:00:00 2001 From: yeseung00 Date: Fri, 14 Nov 2025 17:19:11 +0900 Subject: [PATCH 19/20] =?UTF-8?q?Feat:=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20UI=20=EB=94=94=EC=9E=90=EC=9D=B8,?= =?UTF-8?q?=20=EB=93=9C=EB=A1=AD=EB=8B=A4=EC=9A=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 생성하기 버튼에 공용 컴포넌트 사용 - 폼 인풋 스타일 적용 - ‘상대와의 관계’, ‘폰트선택’ 드롭다운 컴포넌트 구현# --- src/App.jsx | 10 +++++----- src/components/message/drop-down.jsx | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 928e238..d283b13 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -17,13 +17,13 @@ function App() { }> } /> - } /> + {/*} /> } /> } /> - } /> - } /> - } /> - } /> + } /> */} + } /> + } /> + } /> diff --git a/src/components/message/drop-down.jsx b/src/components/message/drop-down.jsx index 37d34df..e2302e2 100644 --- a/src/components/message/drop-down.jsx +++ b/src/components/message/drop-down.jsx @@ -12,6 +12,7 @@ const DropDownWrapper = styled.div` const DropDownTrigger = styled.button` width: 100%; + height: 50px; display: flex; justify-content: space-between; align-items: center; From c8e6a67d07037e8842ba2f63f3d03b94739585d7 Mon Sep 17 00:00:00 2001 From: yeseung00 Date: Sat, 15 Nov 2025 04:00:12 +0900 Subject: [PATCH 20/20] =?UTF-8?q?Feat:=20reach=20text=20editor=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 1 + package.json | 1 + src/components/message/reach-text-editor.jsx | 64 ++++++++++---------- 3 files changed, 35 insertions(+), 31 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7e44d9c..1bbe48d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "axios": "^1.13.2", "quill": "^2.0.3", "emoji-picker-react": "^4.15.0", + "quill": "^2.0.3", "react": "^19.1.1", "react-dom": "^19.1.1", "react-quill-new": "^3.6.0", diff --git a/package.json b/package.json index e673689..805b901 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "axios": "^1.13.2", "quill": "^2.0.3", "emoji-picker-react": "^4.15.0", + "quill": "^2.0.3", "react": "^19.1.1", "react-dom": "^19.1.1", "react-quill-new": "^3.6.0", diff --git a/src/components/message/reach-text-editor.jsx b/src/components/message/reach-text-editor.jsx index 27abe38..069e683 100644 --- a/src/components/message/reach-text-editor.jsx +++ b/src/components/message/reach-text-editor.jsx @@ -1,12 +1,20 @@ -import React, { useMemo } from "react"; +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 toolbar = Quill.import("modules/toolbar"); +const list = Quill.import("formats/list"); + +if (list) { + Quill.register(list, true); +} +if (toolbar) { +} const EditorContainer = styled.div` min-height: 243px; @@ -46,36 +54,30 @@ const EditorContainer = styled.div` `; function RichTextEditor({ value, onChange }) { - const modules = useMemo( - () => ({ - toolbar: [ - ["bold", "italic", "underline"], - [ - { align: "" }, - { align: "center" }, - { align: "right" }, - { align: "justify" }, - ], - [{ list: "ordered" }, { list: "bullet" }], - ["link", "image"], + const modules = { + toolbar: [ + ["bold", "italic", "underline"], + [ + { align: "" }, + { align: "center" }, + { align: "right" }, + { align: "justify" }, ], - }), - [] - ); - - const formats = useMemo( - () => [ - "bold", - "italic", - "underline", - "align", - "list", - "bullet", - "link", - "image", + [{ list: "ordered" }, { list: "bullet" }], + ["link", "image"], ], - [] - ); + }; + + const formats = [ + "bold", + "italic", + "underline", + "align", + "list", + "bullet", + "link", + "image", + ]; return (