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/.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/index.html b/index.html index d4c7e2a..cd6c3dd 100644 --- a/index.html +++ b/index.html @@ -14,6 +14,7 @@
+
diff --git a/package-lock.json b/package-lock.json index 0982c14..82e0efa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,15 @@ "version": "0.0.0", "dependencies": { "axios": "^1.13.2", + "emoji-picker-react": "^4.15.0", + "quill": "^2.0.3", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-infinite-scroll-component": "^6.1.0", + "react-quill-new": "^3.6.0", "react-router": "^7.9.5", - "styled-components": "^6.1.19" + "styled-components": "^6.1.19", + "swiper": "^12.0.3" }, "devDependencies": { "@eslint/js": "^9.36.0", @@ -1814,6 +1819,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", @@ -2101,6 +2121,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2108,6 +2134,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "license": "Apache-2.0" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -2170,6 +2202,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", @@ -2460,9 +2498,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -2559,6 +2597,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -2708,6 +2765,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parchment": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz", + "integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==", + "license": "BSD-3-Clause" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -2821,6 +2884,35 @@ "node": ">=6" } }, + "node_modules/quill": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz", + "integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==", + "license": "BSD-3-Clause", + "dependencies": { + "eventemitter3": "^5.0.1", + "lodash-es": "^4.17.21", + "parchment": "^3.0.0", + "quill-delta": "^5.1.0" + }, + "engines": { + "npm": ">=8.2.3" + } + }, + "node_modules/quill-delta": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz", + "integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==", + "license": "MIT", + "dependencies": { + "fast-diff": "^1.3.0", + "lodash.clonedeep": "^4.5.0", + "lodash.isequal": "^4.5.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/react": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", @@ -2842,6 +2934,33 @@ "react": "^19.2.0" } }, + "node_modules/react-infinite-scroll-component": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/react-infinite-scroll-component/-/react-infinite-scroll-component-6.1.0.tgz", + "integrity": "sha512-SQu5nCqy8DxQWpnUVLx7V7b7LcA37aM7tvoWjTLZp1dk6EJibM5/4EJKzOnl07/BsM1Y40sKLuqjCwwH/xV0TQ==", + "license": "MIT", + "dependencies": { + "throttle-debounce": "^2.1.0" + }, + "peerDependencies": { + "react": ">=16.0.0" + } + }, + "node_modules/react-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", @@ -3074,6 +3193,34 @@ "node": ">=8" } }, + "node_modules/swiper": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/swiper/-/swiper-12.0.3.tgz", + "integrity": "sha512-BHd6U1VPEIksrXlyXjMmRWO0onmdNPaTAFduzqR3pgjvi7KfmUCAm/0cj49u2D7B0zNjMw02TSeXfinC1hDCXg==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/swiperjs" + }, + { + "type": "open_collective", + "url": "http://opencollective.com/swiper" + } + ], + "license": "MIT", + "engines": { + "node": ">= 4.7.0" + } + }, + "node_modules/throttle-debounce": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-2.3.0.tgz", + "integrity": "sha512-H7oLPV0P7+jgvrk+6mwwwBDmxTaxnu9HMXmloNLXwnNO0ZxZ31Orah2n8lU1eMPvsaowP2CX+USCgyovXfdOFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", diff --git a/package.json b/package.json index 8045356..e8e3e76 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,15 @@ }, "dependencies": { "axios": "^1.13.2", + "quill": "^2.0.3", + "emoji-picker-react": "^4.15.0", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-quill-new": "^3.6.0", + "react-infinite-scroll-component": "^6.1.0", "react-router": "^7.9.5", - "styled-components": "^6.1.19" + "styled-components": "^6.1.19", + "swiper": "^12.0.3" }, "devDependencies": { "@eslint/js": "^9.36.0", diff --git a/src/App.jsx b/src/App.jsx index e7b73a5..b55b91a 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,19 +1,26 @@ -import { BrowserRouter, Routes, Route } from "react-router"; +import { Routes, Route } from "react-router"; import { GlobalStyle } from "@/styles/global-style"; -import GlobalLayout from "@/components/global-layout"; -import TestPage from "@/pages/test-page"; +import GlobalLayout from "@/components/common/global-layout"; +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 RollingPage from "@/pages/rolling-page"; function App() { return ( <> - - - }> - } /> - - - + + }> + } /> + } /> + } /> + } /> + } /> + } /> + + ); } diff --git a/src/api/client.js b/src/api/client.js new file mode 100644 index 0000000..8b0ce0a --- /dev/null +++ b/src/api/client.js @@ -0,0 +1,22 @@ +import axios from "axios"; + +// 기수-팀 번호 설정 (환경변수로 관리 가능) +const TEAM_CODE = "20-1"; // 추후 환경변수로 변경 가능 + +// API 기본 설정 +const BASE_URL = `https://rolling-api.vercel.app/${TEAM_CODE}`; + +/** + * API 클라이언트 + * 책임: axios 인스턴스 생성 및 기본 설정 + */ +const apiClient = axios.create({ + baseURL: BASE_URL, + timeout: 10000, + headers: { + "Content-Type": "application/json", + }, +}); + +export default apiClient; +export { TEAM_CODE }; diff --git a/src/api/list-user-api.js b/src/api/list-user-api.js new file mode 100644 index 0000000..27a28db --- /dev/null +++ b/src/api/list-user-api.js @@ -0,0 +1,7 @@ +// 카드 리스트 유저 GET +import axios from "axios"; + +export async function getRecipients({ url }) { + const res = await axios.get(url); + return res.data; +} diff --git a/src/api/rolling-page-api.js b/src/api/rolling-page-api.js new file mode 100644 index 0000000..e23567b --- /dev/null +++ b/src/api/rolling-page-api.js @@ -0,0 +1,86 @@ +import apiClient from "./client"; + +/** + * Recipients API 함수들 + * 책임: Recipients 관련 API 호출 + */ + +// 유저 상세 조회 + +export const getRecipientById = async (recipientId) => { + try { + const response = await apiClient.get(`/recipients/${recipientId}/`); + return response.data; + } catch (error) { + console.error(`Failed to fetch recipient ${recipientId}:`, error); + throw error; + } +}; + +// 롤링 페이퍼 전체 삭제 + +export const deleteRecipient = async (recipientId) => { + try { + const response = await apiClient.delete(`/recipients/${recipientId}/`); + return response.data; + } catch (error) { + console.error(`Failed to delete recipient ${recipientId}:`, error); + throw error; + } +}; + + +// 유저의 모든 리액션 조회 + +export const getReactions = async (recipientId, params = {}) => { + try { + const response = await apiClient.get(`/recipients/${recipientId}/reactions/`, { + params, + }); + return response.data; + } catch (error) { + console.error(`Failed to fetch reactions for recipient ${recipientId}:`, error); + throw error; + } +}; + + +// 수신자에게 리액션 추가/감소 + +export const addReaction = async (recipientId, data) => { + try { + const response = await apiClient.post(`/recipients/${recipientId}/reactions/`, data); + return response.data; + } catch (error) { + console.error(`Failed to add reaction to recipient ${recipientId}:`, error); + throw error; + } +}; + + +// 수신자의 메시지 목록 조회 + +export const getRecipientMessages = async (recipientId, { limit = 6, offset = 0 } = {}) => { + try { + const response = await apiClient.get(`/recipients/${recipientId}/messages/`, { + params: { limit, offset }, + }); + return response.data; + } catch (error) { + console.error(`Failed to fetch messages for recipient ${recipientId}:`, error); + throw error; + } +}; + +// 메시지 삭제 + +export const deleteMessage = async (messageId) => { + try { + const response = await apiClient.delete(`/messages/${messageId}/`); + return response.data; + } catch (error) { + console.error(`Failed to delete message ${messageId}:`, error); + throw error; + } +}; + diff --git a/src/assets/icons/deleted-red.svg b/src/assets/icons/deleted-red.svg new file mode 100644 index 0000000..e051b72 --- /dev/null +++ b/src/assets/icons/deleted-red.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/bg-pattern-beige.svg b/src/assets/images/bg-pattern-beige.svg new file mode 100644 index 0000000..e1b9e8d --- /dev/null +++ b/src/assets/images/bg-pattern-beige.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/bg-pattern-blue.svg b/src/assets/images/bg-pattern-blue.svg new file mode 100644 index 0000000..37b626c --- /dev/null +++ b/src/assets/images/bg-pattern-blue.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/bg-pattern-green.svg b/src/assets/images/bg-pattern-green.svg new file mode 100644 index 0000000..59c771b --- /dev/null +++ b/src/assets/images/bg-pattern-green.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/bg-pattern-purple.svg b/src/assets/images/bg-pattern-purple.svg new file mode 100644 index 0000000..6d22b58 --- /dev/null +++ b/src/assets/images/bg-pattern-purple.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/img-car.webp b/src/assets/images/img-car.webp new file mode 100644 index 0000000..092e633 Binary files /dev/null and b/src/assets/images/img-car.webp differ diff --git a/src/assets/images/img-park.webp b/src/assets/images/img-park.webp new file mode 100644 index 0000000..bec9c18 Binary files /dev/null and b/src/assets/images/img-park.webp differ diff --git a/src/assets/images/main-visual-01.webp b/src/assets/images/main-visual-01.webp new file mode 100644 index 0000000..3bb5a59 Binary files /dev/null and b/src/assets/images/main-visual-01.webp differ diff --git a/src/assets/images/main-visual-02.webp b/src/assets/images/main-visual-02.webp new file mode 100644 index 0000000..fea0756 Binary files /dev/null and b/src/assets/images/main-visual-02.webp differ diff --git a/src/assets/images/select-circle.webp b/src/assets/images/select-circle.webp new file mode 100644 index 0000000..88fb41c Binary files /dev/null and b/src/assets/images/select-circle.webp differ diff --git a/src/components/common/button.jsx b/src/components/common/button.jsx new file mode 100644 index 0000000..8ba1af4 --- /dev/null +++ b/src/components/common/button.jsx @@ -0,0 +1,191 @@ +import styled, { css } from "styled-components"; +import { colors } from "@/styles/colors"; +import { font } from "@/styles/font"; +import plusIcon from "@/assets/icons/plus.svg"; +import deleteIcon from "@/assets/icons/deleted.svg"; +import addEmojiIcon from "@/assets/icons/add-emoji.svg"; + +const SIZES = { + large: css` + padding: 14px 24px; + border-radius: 12px; + ${font.bold18} + `, + medium: css` + padding: 7px 16px; + border-radius: 6px; + ${font.regular16} + `, + small: css` + padding: 6px 16px; + border-radius: 6px; + ${font.regular16} + `, + tiny: css` + padding: 2px 16px; + border-radius: 6px; + ${font.regular14} + `, + plus: css` + padding: 16px; + border-radius: 9999px; + `, + delete: css` + padding: 6px; + border-radius: 6px; + `, +}; + +const VARIANT_STYLES = { + primary: css` + background-color: ${colors.purple[600]}; + color: white; + border: none; + + &:hover { + background-color: ${colors.purple[700]}; + } + + &:active { + background-color: ${colors.purple[800]}; + } + + &:focus { + background-color: ${colors.purple[900]}; + } + `, + + secondary: css` + background-color: white; + color: ${colors.purple[700]}; + border: 1px solid ${colors.purple[600]}; + + &:hover { + background-color: ${colors.purple[100]}; + color: ${colors.purple[600]}; + border-color: ${colors.purple[700]}; + } + + &:active { + background-color: ${colors.purple[100]}; + color: ${colors.purple[600]}; + border-color: ${colors.purple[800]}; + } + + &:focus { + color: ${colors.purple[600]}; + border-color: ${colors.purple[800]}; + } + `, + + outlined: css` + background-color: white; + color: ${colors.gray[900]}; + border: 1px solid ${colors.gray[300]}; + + &:hover { + background-color: ${colors.gray[100]}; + } + + &:active { + background-color: ${colors.gray[100]}; + } + + &:focus { + border-color: ${colors.gray[500]}; + } + + .emoji-icon { + width: 24px; + height: 24px; + } + `, + + plus: css` + background-color: ${colors.gray[500]}; + border: 1px solid transparent; + + &:hover { + background-color: ${colors.gray[600]}; + } + + &:active { + background-color: ${colors.gray[700]}; + } + + &:focus { + background-color: ${colors.gray[700]}; + border: 1px solid ${colors.gray[800]}; + } + + .plus-icon { + width: 24px; + height: 24px; + } + `, + + delete: css` + background-color: white; + border: 1px solid ${colors.gray[300]}; + + &:hover { + background-color: ${colors.gray[100]}; + } + + &:active { + background-color: ${colors.gray[100]}; + } + + &:focus { + border-color: 1px solid ${colors.gray[500]}; + } + + .delete-icon { + width: 24px; + height: 24px; + } + `, +}; + +const ButtonStyle = styled.button` + display: flex; + justify-content: center; + align-items: center; + gap: 10px; + cursor: pointer; + transition: all 0.2s ease-in-out; + ${({ $variant }) => VARIANT_STYLES[$variant] || VARIANT_STYLES.primary} + ${({ $size }) => SIZES[$size] || SIZES.medium} + + &:disabled { + background-color: ${colors.gray[300]}; + color: white; + border: transparent; + cursor: not-allowed; + } +`; + +export default function Button({ + children, + variant = "primary", + size = "medium", + emoji = "", + ...props +}) { + return ( + + {variant === "plus" ? ( + 추가 + ) : variant === "delete" ? ( + 삭제 + ) : variant === "outlined" && emoji ? ( + <> + 이모지 + {children} + + ) : ( + children + )} + + ); +} diff --git a/src/components/common/global-layout.jsx b/src/components/common/global-layout.jsx new file mode 100644 index 0000000..dc1d37d --- /dev/null +++ b/src/components/common/global-layout.jsx @@ -0,0 +1,16 @@ +import { Outlet, useLocation } from "react-router"; +import Header from "@/components/common/header"; + +const PAGES_WITH_BUTTON = ["main", "list"]; + +export default function GlobalLayout() { + const location = useLocation(); + const showButton = PAGES_WITH_BUTTON.some((page) => location.pathname.includes(page)); + + return ( + <> +
+ + + ); +} diff --git a/src/components/common/header.jsx b/src/components/common/header.jsx new file mode 100644 index 0000000..88c9d00 --- /dev/null +++ b/src/components/common/header.jsx @@ -0,0 +1,77 @@ +import { Link, useLocation } from "react-router"; +import styled from "styled-components"; +import logo from "@/assets/icons/logo.svg"; +import Button from "@/components/common/button"; +import media from "@/styles/media"; + +const ContainWrapper = styled.div` + position: sticky; + top: 0; + background-color: white; + border-bottom: 1px solid #ededed; + z-index: 1003; + + ${(props) => + props.$isRollingPage && + media.small` + display: none; + `} +`; + +const Contain = styled.div` + max-width: 1248px; + margin: 0 auto; +`; + +const HeaderWrapper = styled.div` + display: flex; + justify-content: start; + align-items: center; + gap: 8px; + margin: 0 24px; +`; + +const LogoWrapper = styled.div` + display: flex; + align-items: center; + gap: 8px; +`; + +const Title = styled.h3` + padding: 20px 0; +`; + +const ButtonWrapper = styled.div` + margin-left: auto; +`; + +export default function Header({ showButton }) { + const location = useLocation(); + const isRollingPage = location.pathname.startsWith("/post/"); + + return ( + <> + + + + + + 로고 + Rolling + + + {showButton && ( + + + + + + )} + + + + + ); +} diff --git a/src/components/common/modal-layout.jsx b/src/components/common/modal-layout.jsx new file mode 100644 index 0000000..ba9ba32 --- /dev/null +++ b/src/components/common/modal-layout.jsx @@ -0,0 +1,90 @@ +import React from "react"; +import styled from "styled-components"; +import { colors } from "@/styles/colors"; +import { font } from "@/styles/font"; +import media from "@/styles/media"; + +const Overlay = styled.div` + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 999; +`; + +const ModalContainer = styled.div` + background: white; + border-radius: 16px; + padding: 40px; + width: 600px; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + + ${media.medium` + width: 600px; + padding: 30px; + `} + + ${media.small` + width: 320px; + padding: 24px; + `} +`; + +const ModalTitle = styled.h2` + ${font.bold24} + color: ${colors.gray[900]}; + margin-bottom: 24px; + text-align: center; +`; + +const ModalContent = styled.div` + width: 100%; +`; + +const CloseButton = styled.button` + width: 100%; + margin-top: 16px; + padding: 6px; + background: transparent; + border: 1px solid ${colors.gray[300]}; + border-radius: 8px; + cursor: pointer; + ${font.regular16} + color: ${colors.gray[700]}; + transition: all 0.2s; + + &:hover { + background: ${colors.gray[50]}; + } +`; + +/** + * 공통 모달 레이아웃 컴포넌트 + * 책임: 모달의 기본 구조와 레이아웃 제공 + */ +export default function ModalLayout({ + isOpen, + onClose, + title, + children, + showCloseButton = true, +}) { + if (!isOpen) return null; + + return ( + + e.stopPropagation()}> + {title && {title}} + {children} + {showCloseButton && 닫기} + + + ); +} diff --git a/src/components/common/toast-provider.jsx b/src/components/common/toast-provider.jsx new file mode 100644 index 0000000..97f7154 --- /dev/null +++ b/src/components/common/toast-provider.jsx @@ -0,0 +1,83 @@ +import { useState, useRef, useCallback } from "react"; +import { createPortal } from "react-dom"; +import Toast from "@/components/common/toast"; +import { ToastContext } from "@/hooks/use-toast"; + +export function ToastProvider({ children }) { + const [toast, setToast] = useState(null); + + const autoCloseTimerRef = useRef(null); + const closeAnimTimerRef = useRef(null); + + const hideToast = useCallback(() => { + if (autoCloseTimerRef.current) { + clearTimeout(autoCloseTimerRef.current); + autoCloseTimerRef.current = null; + } + + if (closeAnimTimerRef.current) { + clearTimeout(closeAnimTimerRef.current); + closeAnimTimerRef.current = null; + } + + setToast((prev) => { + if (!prev || prev.isClosing) return prev; + return { ...prev, isClosing: true }; + }); + + closeAnimTimerRef.current = setTimeout(() => { + setToast(null); + closeAnimTimerRef.current = null; + }, 300); + }, []); + + 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, + }); + + autoCloseTimerRef.current = setTimeout(() => { + hideToast(); + }, 5000); + }, + [hideToast] + ); + + const contextValue = { + success: (message) => showToast(message, "success"), + delete: (message) => showToast(message, "delete"), + }; + + const toastContainer = document.getElementById("toast"); + + return ( + + {children} + {toastContainer && + createPortal( + <> + {toast && ( + + {toast.message} + + )} + , + toastContainer, + )} + + ); +} diff --git a/src/components/common/toast.jsx b/src/components/common/toast.jsx new file mode 100644 index 0000000..7ab8c90 --- /dev/null +++ b/src/components/common/toast.jsx @@ -0,0 +1,84 @@ +import styled, { keyframes } from "styled-components"; +import completedIcon from "@/assets/icons/completed.svg"; +import deletedRedIcon from "@/assets/icons/deleted-red.svg"; +import closeIcon from "@/assets/icons/close.svg"; +import { font } from "@/styles/font"; +import media from "@/styles/media"; + +const fadeIn = keyframes` + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +`; + +const fadeOut = keyframes` + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(20px); + } +`; + +const ToastStyle = styled.div` + display: flex; + justify-content: start; + align-items: center; + gap: 12px; + position: fixed; + background-color: rgba(0, 0, 0, 0.8); + color: white; + padding: 19px 30px; + border-radius: 8px; + animation: ${({ $isClosing }) => ($isClosing ? fadeOut : fadeIn)} 0.3s ease-in-out forwards; + ${font.regular16} + + button { + background-color: transparent; + border: none; + cursor: pointer; + margin-left: auto; + } + + ${media.large` + width: 524px; + left: calc(50% - 262px); + bottom: 70px; + `} + + ${media.medium` + width: 524px; + position: absolute; + left: calc(50% - 262px); + bottom: 10%; + transform: translate(-50%, 50%); + `} + + ${media.small` + width: 320px; + left: calc(50% - 160px); + bottom: 88px; + `} +`; + +export default function Toast({ children, type, isClosing, onClose }) { + return ( + + {type + {children} + + + ); +} diff --git a/src/components/common/toggle.jsx b/src/components/common/toggle.jsx new file mode 100644 index 0000000..d1ec36a --- /dev/null +++ b/src/components/common/toggle.jsx @@ -0,0 +1,203 @@ +import styled from "styled-components"; +import { colors } from "@/styles/colors"; +import { font } from "@/styles/font"; +import media from "@/styles/media"; +import { useEffect, useState } from "react"; +import axios from "axios"; +import selectCircle from "@/assets/images/select-circle.webp"; + +const ToggleSection = styled.div` + width: 100%; + min-width: 360px; + display: flex; + flex-direction: column; + gap: 17px; +`; + +const ToggleTitle = styled.p` + ${font.bold24}; + color: ${colors.gray[900]}; + margin: 0; +`; + +const ToggleTitleSmall = styled.p` + ${font.regular16}; + color: ${colors.gray[600]}; + margin: 0; +`; + +const ToggleButtonContainer = styled.div` + width: 224px; + height: 40px; + background-color: ${colors.gray[100]}; + border-radius: 6px; + display: flex; + flex-direction: row; + margin-top: 20px; +`; + +const ToggleButton = styled.div` + width: 122px; + height: 40px; + background-color: ${(props) => (props.$active ? "transparent" : "#ffffff")}; + color: ${(props) => (props.$active ? colors.gray[900] : colors.purple[600])}; + border: ${(props) => + props.$active ? null : `2px solid ${colors.purple[600]}`}; + ${(props) => (props.$active ? `${font.regular16}` : `${font.bold16}`)}; + border-radius: 6px; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; +`; + +const ToggleButtonDisable = styled.div` + width: 122px; + height: 40px; + background-color: ${(props) => (props.$active ? "transparent" : "#ffffff")}; + color: ${(props) => (props.$active ? colors.gray[900] : colors.purple[600])}; + border: ${(props) => + props.$active ? null : `2px solid ${colors.purple[600]}`}; + ${(props) => (props.$active ? `${font.regular16}` : `${font.bold16}`)}; + border-radius: 6px; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; +`; + +const ToggleDivContainer = styled.div` + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 16px; + margin-top: 40px; +`; + +const ToggleDiv = styled.div` + width: 168px; + height: 168px; + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 16px; + background-color: ${(props) => props.$bgColor}; + cursor: pointer; + background-image: ${(props) => + props.$active ? `url(${selectCircle})` : null}; + background-repeat: no-repeat; + background-size: 44px 44px; + background-position: center; + + ${media.small` + flex: 1 1 40%; + `} + + ${media.medium` + flex: 1 1 40%; + `}; +`; + +const ToggleImgContainer = styled.div` + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 16px; + margin-top: 40px; +`; + +const ToggleImg = styled.div` + width: 168px; + height: 168px; + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 16px; + background-image: ${(props) => + props.$active ? `url(${selectCircle})` : `url(${props.bgImgs})`}, + url(${(props) => props.$bgImgs}); + background-repeat: no-repeat; + background-size: 44px 44px, 180%; + background-position: center; + cursor: pointer; + + ${media.small` + flex: 1 1 40%; + `} + + ${media.medium` + flex: 1 1 40%; + `} +`; + +export default function Toggle({ + bgColors, + isSelectDiv, + setIsSelectDiv, + isSelectImg, + setIsSelectImg, +}) { + const [imgs, setImgs] = useState([]); + const [toggle, setToggle] = useState(false); + + useEffect(() => { + axios + .get("https://rolling-api.vercel.app/background-images/") + .then((response) => { + setImgs(response.data.imageUrls); + + // if (response.data.imageUrls.length > 0) { + // setIsSelectImg(response.data.imageUrls[0]); + // } + }) + .catch((error) => { + console.error("배경 이미지 가져오기에 실패했습니다.", error); + }); + }, [setIsSelectImg]); + + const handleToggle = () => { + setToggle(!toggle); + }; + + const handleClickDiv = (bgColor) => { + setIsSelectDiv(bgColor); + }; + + const handleClickImg = (bgImg) => { + setIsSelectImg(bgImg); + }; + + return ( + <> + + 배경화면을 선택해 주세요. + + 컬러를 선택하거나, 이미지를 선택할 수 있습니다. + + + 컬러 + 이미지 + + {toggle === false ? ( + + {bgColors.map((bgColor) => ( + handleClickDiv(bgColor)} + $active={isSelectDiv === bgColor} + $bgColor={bgColor} + /> + ))} + + ) : ( + + {imgs.map((bgImg) => ( + handleClickImg(bgImg)} + $active={isSelectImg === bgImg} + $bgImgs={bgImg} + /> + ))} + + )} + + + ); +} diff --git a/src/components/global-layout.jsx b/src/components/global-layout.jsx deleted file mode 100644 index 7fd6d55..0000000 --- a/src/components/global-layout.jsx +++ /dev/null @@ -1,9 +0,0 @@ -import { Outlet } from "react-router"; - -export default function GlobalLayout() { - return ( -
- -
- ); -} diff --git a/src/components/list/card-list.jsx b/src/components/list/card-list.jsx new file mode 100644 index 0000000..30d0af1 --- /dev/null +++ b/src/components/list/card-list.jsx @@ -0,0 +1,130 @@ +import { Swiper, SwiperSlide } from "swiper/react"; +import { Navigation } from "swiper/modules"; +import { useNavigate } from "react-router"; +import "swiper/css"; +import "swiper/css/navigation"; +import EmojiDisplayList from "@/components/rolling/emoji-display-list"; +import { + CardImgWrapper, + CardListLayout, + SwiperWrapper, + CardWrapper, + EmojiWrapper, + WriterCountText, + ProfileCount, + EmptySection, + Title, + ReceiverName, +} from "@/styles/list-page-styles"; + +export function CardList({ title, userList, onLoadMore, nextCheck }) { + const isDesktop = window.innerWidth >= 1024; + const navigate = useNavigate(); + + const handleCardClick = (id) => { + navigate(`/post/${id}`); + }; + + const handleLoadMore = async () => { + if (!onLoadMore || !nextCheck) return; + await onLoadMore(); + }; + + return ( + <> + + {title} + {userList.length === 0 ? ( + + 아직 작성된 롤링 페이퍼가 없습니다. +
+ 새로운 롤링 페이퍼를 만들어 보세요! +
+ ) : ( + + { + if (!isDesktop) return; + handleLoadMore(); + }} + onSlideChange={(swiper) => { + if (isDesktop) return; + const current = swiper.activeIndex; + const last = swiper.slides.length - 1; + if (current >= last - 2) { + handleLoadMore(); + } + }} + navigation={true} + allowTouchMove={!isDesktop} + slidesPerView="auto" + slidesPerGroup={1} + spaceBetween={12} + slidesOffsetBefore={20} + slidesOffsetAfter={20} + breakpoints={{ + 600: { + slidesOffsetBefore: 24, + slidesOffsetAfter: 24, + }, + 1024: { + slidesPerView: 3, + slidesPerGroup: 3, + spaceBetween: 20, + allowTouchMove: false, + slidesOffsetBefore: 0, + slidesOffsetAfter: 0, + }, + 1200: { + slidesPerView: 4, + slidesPerGroup: 4, + spaceBetween: 20, + allowTouchMove: false, + slidesOffsetBefore: 0, + slidesOffsetAfter: 0, + }, + }} + > + {userList.map((it) => { + return ( + handleCardClick(it.id)} + > + +
+ + To. {it.name} + + + {/* 프로필 이미지 map */} + {it.recentMessages.slice(0, 3).map((it) => ( + + ))} + {it.messageCount > 3 && ( + +{it.messageCount - 3} + )} + {/* -------------- */} + + + {it.messageCount}명이 작성했어요! + +
+ + + +
+
+ ); + })} +
+
+ )} +
+ + ); +} diff --git a/src/components/message/drop-down.jsx b/src/components/message/drop-down.jsx new file mode 100644 index 0000000..e2302e2 --- /dev/null +++ b/src/components/message/drop-down.jsx @@ -0,0 +1,159 @@ +import React, { useState, useRef, useEffect, useCallback } from "react"; +import styled, { css } from "styled-components"; +import { colors } from "@/styles/colors"; +import { font } from "@/styles/font"; + +import ARROW_ICON from "@/assets/icons/arrow-right.svg"; + +const DropDownWrapper = styled.div` + position: relative; + width: 320px; +`; + +const DropDownTrigger = styled.button` + width: 100%; + height: 50px; + display: flex; + justify-content: space-between; + align-items: center; + + padding: 12px 16px; + border-radius: 8px; + border: 1px solid ${colors.gray[300]}; + background-color: #fff; + + ${font.regular16} + text-align: left; + + outline: none; + + color: ${({ $currentValue, defaultValue, $isInitialLoad }) => { + if ($isInitialLoad && $currentValue === defaultValue) { + return colors.gray[500]; + } + return colors.gray[900]; + }}; + + ${({ $isOpen }) => + $isOpen && + css` + border: 2px solid ${colors.gray[500]}; + padding: 11px 15px; + `} +`; + +const ArrowImage = styled.img` + width: 16px; + height: 16px; + transform: rotate(${({ $isOpen }) => ($isOpen ? "270deg" : "90deg")}); + transition: transform 0.2s; +`; + +const DropDownMenuContainer = styled.ul` + list-style: none; + margin: 10px 1px; + padding: 1px; + + position: absolute; + top: 100%; + left: 0; + z-index: 10; + width: 320px; + max-height: 220px; + overflow-y: auto; + + background-color: #ffffff; + border: 1px solid ${colors.gray[300]}; + border-radius: 8px; + + box-shadow: 0px 2px 12px 0px rgba(0, 0, 0, 0.08); +`; + +const DropDownItem = styled.li` + height: 50px; + display: flex; + align-items: center; + padding: 12px 16px; + + ${font.regular16} + color: ${colors.gray[900]}; + + &:hover { + background-color: ${colors.gray[100]}; + } +`; + +function DropDown({ id, name, defaultValue, value, onChange, options }) { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + const currentValue = value; + const [isInitialLoad, setIsInitialLoad] = useState(true); + + const handleClickOutside = useCallback((event) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { + setIsOpen(false); + } + }, []); + + useEffect(() => { + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [handleClickOutside]); + + const handleItemClick = (optionValue) => { + onChange({ target: { name: name, value: optionValue } }); + setIsOpen(false); + setIsInitialLoad(false); + }; + + const handleTriggerClick = () => { + setIsOpen((prev) => !prev); + if (isInitialLoad) { + setIsInitialLoad(false); + } + }; + + const selectedOption = options.find((opt) => opt.value === currentValue) || { + label: defaultValue, + value: defaultValue, + }; + + return ( + + + {selectedOption.label} + + + + {isOpen && ( + + {options.map((option) => ( + handleItemClick(option.value)} + > + {option.label} + + ))} + + )} + + ); +} + +export default DropDown; diff --git a/src/components/message/from-input.jsx b/src/components/message/from-input.jsx new file mode 100644 index 0000000..16a1e58 --- /dev/null +++ b/src/components/message/from-input.jsx @@ -0,0 +1,47 @@ +import React from "react"; +import styled, { css } from "styled-components"; +import { colors } from "@/styles/colors"; +import { + FormInputStyle, + ErrorMessage, + FormField, +} from "@/pages/message-page.jsx"; + +const ErrorOverrideStyle = css` + border-color: ${colors.error}; + + &:focus { + border-color: ${colors.error}; + } +`; +const StyledInput = styled.input` + ${() => FormInputStyle} + ${(props) => props.$hasError && ErrorOverrideStyle} +`; + +export default function FromInput({ + id, + name, + placeholder, + hasError, + errorMessage, + onBlur, + onChange, + value, +}) { + return ( + + + {/* 에러 메시지 표시 */} + {hasError && {errorMessage}} + + ); +} diff --git a/src/components/message/profile-image-selector.jsx b/src/components/message/profile-image-selector.jsx new file mode 100644 index 0000000..b387e85 --- /dev/null +++ b/src/components/message/profile-image-selector.jsx @@ -0,0 +1,148 @@ +import React from "react"; +import styled from "styled-components"; +import { colors } from "@/styles/colors"; +import { font } from "@/styles/font"; +import { DEFAULT_IMAGE_ID } from "@/hooks/use-profile-image"; +import defaultIcon from "@/assets/icons/person.svg"; + +const DEFAULT_ICON_URL = defaultIcon; + +const FormLabel = styled.label` + ${font.bold24} + line-height: 36px; + letter-spacing: -0.01em; + color: ${colors.gray[900]}; + margin: 0; + padding: 0; +`; + +const ProfileWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 12px; +`; + +const SelectorPrompt = styled.p` + ${font.regular16}; + color: ${colors.gray[500]}; + margin: 0; + padding: 0; +`; + +const ProfileSelectorContainer = styled.div` + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 32px; + padding: 4px 0; +`; + +const SelectorRightBlock = styled.div` + display: flex; + flex-direction: column; + gap: 12px; +`; + +const ProfileDefaultBox = styled.div` + width: 80px; + height: 80px; + border-radius: 50%; + overflow: hidden; + flex-shrink: 0; + background-color: ${colors.gray[300]}; + + border: 1px solid + ${(props) => (props.$isSelected ? colors.purple[700] : colors.gray[200])}; + ${(props) => props.$isSelected && `border-width: 2px;`} + + cursor: pointer; + transition: border 0.2s; + + display: flex; + justify-content: center; + align-items: center; + + img { + width: 32px; + height: 32px; + object-fit: contain; + border: none; + } +`; + +const SelectableImagesList = styled.ul` + display: flex; + flex-wrap: wrap; + gap: 0px; + + list-style: none; + margin: 0; + padding: 0; +`; + +const SelectableImageItem = styled.li` + width: 56px; + height: 56px; + border-radius: 50%; + overflow: hidden; + cursor: pointer; + flex-shrink: 0; + transition: border 0.2s; + + border: 2px solid + ${(props) => (props.$isSelected ? colors.purple[700] : "transparent")}; + + &:hover { + opacity: 0.8; + } + + img { + width: 100%; + height: 100%; + object-fit: cover; + border: none; + } +`; + +function ProfileImageSelector({ + selectedId, + onImageSelect, + selectableImages, + isLoading, + error, +}) { + return ( + + 프로필 이미지 + + onImageSelect(DEFAULT_IMAGE_ID)} + > + 기본 프로필 아이콘 + + + + 프로필 이미지를 선택해주세요! + + {isLoading &&

이미지를 불러오는 중입니다...

} + {error &&

이미지를 불러오는 데 실패했습니다.

} + {!isLoading && + !error && + selectableImages.map((image) => ( + onImageSelect(image.id)} + > + {`프로필 + + ))} +
+
+
+
+ ); +} + +export default ProfileImageSelector; diff --git a/src/components/message/reach-text-editor.jsx b/src/components/message/reach-text-editor.jsx new file mode 100644 index 0000000..e2be532 --- /dev/null +++ b/src/components/message/reach-text-editor.jsx @@ -0,0 +1,92 @@ +import React from "react"; +import ReactQuill from "react-quill-new"; +import "react-quill-new/dist/quill.snow.css"; + +import styled from "styled-components"; +import { font } from "@/styles/font"; +import { colors } from "@/styles/colors"; +import Quill from "quill"; + +const list = Quill.import("formats/list"); + +if (list) { + Quill.register(list, true); +} + +const EditorContainer = styled.div` + min-height: 243px; + border-radius: 8px; + border: 1px solid #ccc; + overflow: hidden; + + .ql-toolbar.ql-snow { + background-color: #eee; + border: none; + border-bottom: 1px solid #ccc; + padding: 14px 16px; + line-height: 1; + + .ql-formats { + margin-right: 12px; + } + .ql-formats button, + .ql-formats select { + width: 24px; + height: 24px; + padding: 0; + margin-right: 8px; + } + } + + .ql-container.ql-snow { + border: none; + ${font.regular16}; + color: ${colors.gray[900]}; + } + + .ql-editor { + min-height: 200px; + padding: 16px; + } +`; + +function RichTextEditor({ value, onChange }) { + const modules = { + toolbar: [ + ["bold", "italic", "underline"], + [ + { align: "" }, + { align: "center" }, + { align: "right" }, + { align: "justify" }, + ], + [{ list: "ordered" }, { list: "bullet" }], + ["link", "image"], + ], + }; + + const formats = [ + "bold", + "italic", + "underline", + "align", + "list", + "bullet", + "link", + "image", + ]; + + return ( + + + + ); +} + +export default RichTextEditor; diff --git a/src/components/rolling/card-contents.jsx b/src/components/rolling/card-contents.jsx new file mode 100644 index 0000000..c4b1bc6 --- /dev/null +++ b/src/components/rolling/card-contents.jsx @@ -0,0 +1,190 @@ +import React, { useEffect, useState } from "react"; +import { useNavigate } from "react-router"; +import InfiniteScroll from "react-infinite-scroll-component"; +import { + CardContainer, + Card, + CardEditButton, + CardContentContainer, + CardContentStatus, + CardContentStatusContainer, + CardContentStatusProfileImage, + CardContentStatusProfileName, + CardContentStatusRelationship, + CardContentText, + CardContentDate, + CardContentStatusProfileContainer, + CardContentDeleteButton, +} from "@/styles/rolling-page-styles"; +import { useInfiniteRecipientMessages } from "@/hooks/use-infinite-recipients"; +import { useDeleteActions } from "@/hooks/use-delete-actions"; +import CardDetailModal from "./card-detail-modal"; +import DeleteConfirmModal from "./delete-confirm-modal"; + +/** + * 카드 컨텐츠 컴포넌트 (무한 스크롤) + * 책임: 메시지 카드 목록 표시 및 무한 스크롤 처리 + * @param {number} recipientId - 수신자 ID + * @param {boolean} isEditMode - 편집 모드 여부 (true: 편집 가능, false: 뷰어) + */ +export default function CardContents({ recipientId, isEditMode = false }) { + const navigate = useNavigate(); + const { messages, hasMore, fetchInitialData, fetchMoreData, refresh } = + useInfiniteRecipientMessages(recipientId, isEditMode); + + // 삭제 액션 훅 + const { handleDeleteMessage } = useDeleteActions(); + + // 모달 상태 관리 + const [selectedMessage, setSelectedMessage] = useState(null); + const [isDetailModalOpen, setIsDetailModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [messageToDelete, setMessageToDelete] = useState(null); + + // 초기 데이터 로드 + useEffect(() => { + fetchInitialData(); + }, [fetchInitialData]); + + // 카드 클릭 핸들러 + const handleCardClick = (message) => { + setSelectedMessage(message); + setIsDetailModalOpen(true); + }; + + const handleCardEditClick = () => { + navigate(`/post/${recipientId}/message`); + }; + + // 상세 모달 닫기 + const handleCloseDetailModal = () => { + setIsDetailModalOpen(false); + setSelectedMessage(null); + }; + + // 삭제 확인 모달 열기 + const handleOpenDeleteModal = (message) => { + setMessageToDelete(message); + setIsDeleteModalOpen(true); + }; + + // 삭제 확인 모달 닫기 + const handleCloseDeleteModal = () => { + setIsDeleteModalOpen(false); + setMessageToDelete(null); + }; + + // 메시지 삭제 실행 + const handleConfirmDelete = async () => { + if (!messageToDelete) return; + + const success = await handleDeleteMessage(messageToDelete.id, () => { + refresh(); // 목록 갱신 + }); + + if (success) { + handleCloseDeleteModal(); + } + }; + + const formatDate = (dateString) => { + const date = new Date(dateString); + return date + .toLocaleDateString("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }) + .replace(/\. /g, ".") + .replace(/\.$/, ""); // 마지막 점만 제거 + }; + + // 관계 라벨 매핑 + const relationshipMap = { + 친구: "friend", + 가족: "family", + 동료: "colleague", + 지인: "acquaintance", + }; + + return ( + <> + + 모든 메시지를 확인했습니다 +

+ } + > + + {/* 뷰어 모드일 때만 카드 추가 버튼 표시 */} + {!isEditMode && ( + + handleCardEditClick(recipientId)} + /> + + )} + + {messages.map((message) => ( + handleCardClick(message)}> + + + + + + + From. {message.sender} + + + {message.relationship} + + + + + {/* 편집 모드일 때만 카드 삭제 버튼 표시 */} + {isEditMode && ( + { + e.stopPropagation(); // 카드 클릭 이벤트 방지 + handleOpenDeleteModal(message); + }} + /> + )} + + {message.content} + + {formatDate(message.createdAt)} + + + + ))} + +
+ + {/* 카드 상세 모달 */} + + + {/* 삭제 확인 모달 */} + + + ); +} diff --git a/src/components/rolling/card-detail-modal.jsx b/src/components/rolling/card-detail-modal.jsx new file mode 100644 index 0000000..735d98a --- /dev/null +++ b/src/components/rolling/card-detail-modal.jsx @@ -0,0 +1,137 @@ +import React from "react"; +import styled from "styled-components"; +import ModalLayout from "@/components/common/modal-layout"; +import Button from "@/components/common/button"; +import { colors } from "@/styles/colors"; +import { font } from "@/styles/font"; + +const ProfileSection = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + padding-bottom: 20px; + border-bottom: 1px solid ${colors.gray[200]}; +`; + +const ProfileContainer = styled.div` + display: flex; + align-items: center; + gap: 16px; + +`; +const ProfileImage = styled.img` + width: 56px; + height: 56px; + border-radius: 100px; + border: 1px solid ${colors.gray[300]}; +`; + +const ProfileInfo = styled.div` + display: flex; + flex-direction: column; + gap: 6px; +`; + +const ProfileName = styled.div` + ${font.regular16} + color: ${colors.gray[900]}; +`; + +const RelationshipBadge = styled.div` + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 8px; + height: 20px; + border-radius: 4px; + ${font.regular14} + width: fit-content; +`; + +const MessageContent = styled.div` + ${font.regular18} + color:${colors.gray[600]}; + line-height: 24px; + white-space: pre-wrap; + word-break: break-word; + overflow-y: auto; + margin-bottom: 24px; + padding-top: 16px; +`; + +const MessageDate = styled.div` + ${font.regular14} + color: ${colors.gray[400]}; +`; + +const ButtonWrapper = styled.div` + display: flex; + justify-content: center; +`; + +// 관계별 배경색 및 텍스트 색상 +const relationshipColors = { + 친구: { bg: colors.blue[100], text: colors.blue[500] }, + 가족: { bg: colors.green[100], text: colors.green[500] }, + 동료: { bg: colors.purple[100], text: colors.purple[600] }, + 지인: { bg: colors.beige[100], text: colors.beige[500] }, +}; + +/** + * 카드 상세 모달 컴포넌트 + * 책임: 메시지 전체 내용을 모달로 표시 + */ +export default function CardDetailModal({ isOpen, onClose, message }) { + if (!message) return null; + + const relationshipStyle = relationshipColors[message.relationship] || { + bg: colors.gray[100], + text: colors.gray[500], + }; + + // 날짜 포맷팅 + const formatDate = (dateString) => { + const date = new Date(dateString); + return date.toLocaleDateString("ko-KR", { + year: "numeric", + month: "long", + day: "numeric", + }); + }; + + return ( + + + + + + + From. {message.sender} + + + {message.relationship} + + + + {formatDate(message.createdAt)} + + + + {message.content} + + + + + + + ); +} + diff --git a/src/components/rolling/delete-confirm-modal.jsx b/src/components/rolling/delete-confirm-modal.jsx new file mode 100644 index 0000000..f0b2887 --- /dev/null +++ b/src/components/rolling/delete-confirm-modal.jsx @@ -0,0 +1,52 @@ +import React from "react"; +import styled from "styled-components"; +import ModalLayout from "@/components/common/modal-layout"; +import Button from "@/components/common/button"; +import { colors } from "@/styles/colors"; +import { font } from "@/styles/font"; + +const ModalContent = styled.div` + text-align: center; +`; + +const ModalMessage = styled.p` + ${font.regular18} + color: ${colors.gray[700]}; + margin-bottom: 32px; + line-height: 1.6; + white-space: pre-wrap; +`; + +const ButtonGroup = styled.div` + display: flex; + gap: 12px; + justify-content: center; +`; + +/** + * 삭제 확인 모달 컴포넌트 + * 책임: 삭제 전 사용자 확인 받기 + */ +export default function DeleteConfirmModal({ isOpen, onClose, onConfirm, title, message }) { + const handleConfirm = () => { + onConfirm(); + onClose(); + }; + + return ( + + + {message} + + + + + + + ); +} + diff --git a/src/components/rolling/emoji-display-list.jsx b/src/components/rolling/emoji-display-list.jsx new file mode 100644 index 0000000..9432b42 --- /dev/null +++ b/src/components/rolling/emoji-display-list.jsx @@ -0,0 +1,23 @@ +import React from "react"; +import { + RollingHeaderEmojiIconContainer, + RollingHeaderEmojiText, + RollingHeaderEmojiIcon, +} from "@/styles/rolling-page-styles"; + +/** + * 이모지 표시 리스트 컴포넌트 + * 책임: 상위 N개의 이모지를 화면에 표시 + */ +export default function EmojiDisplayList({ emojis }) { + return ( + <> + {emojis.map((emojiData, index) => ( + + {emojiData.emoji} + {emojiData.count} + + ))} + + ); +} diff --git a/src/components/rolling/emoji-dropdown.jsx b/src/components/rolling/emoji-dropdown.jsx new file mode 100644 index 0000000..9afc24f --- /dev/null +++ b/src/components/rolling/emoji-dropdown.jsx @@ -0,0 +1,106 @@ +import React from "react"; +import styled from "styled-components"; +import { colors } from "@/styles/colors"; +import media from "@/styles/media"; +import { font } from "@/styles/font"; +import { RollingHeaderArrowDown } from "@/styles/rolling-page-styles"; + +const EmojiDropdownContainer = styled.div` + position: relative; + display: inline-block; +`; + +const EmojiDropdownWrapper = styled.div` + position: fixed; + transform: translate(-80%, 10%); + z-index: 1000; + background: white; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + border: 1px solid ${colors.gray[300]}; + padding: 24px; + width: auto; + max-height: 300px; + overflow-y: auto; +`; + +const EmojiDropdownGrid = styled.div` + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; + ${media.medium` + grid-template-columns: repeat(3, 1fr); + `} + ${media.small` + grid-template-columns: repeat(3, 1fr); + `} +`; + +const EmojiDropdownItem = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: auto; + height: auto; + padding: 8px 12px; + text-align: center; + border-radius: 32px; + background: rgba(153, 153, 153, 1); + gap: 2px; + + ${media.small` + padding: 4px 8px; + `} +`; + +const EmojiDropdownIcon = styled.div``; + +const EmojiDropdownCount = styled.span` + ${font.regular16} + color: rgba(255, 255, 255, 1); +`; + +const Overlay = styled.div` + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: transparent; + z-index: 999; +`; + +/** + * 이모지 드롭다운 컴포넌트 + * 책임: 이모지 목록을 드롭다운 형태로 표시 (API에서 이미 정렬된 상위 8개) + */ +export default function EmojiDropdown({ + emojis, + isOpen, + onToggle, + onClose, + arrowDownIcon, +}) { + // API에서 이미 카운트 순으로 정렬되어 최대 8개만 제공됨 + const topEmojis = emojis; + return ( + + + {isOpen && ( + <> + + + + {topEmojis.map((emojiData, index) => ( + + {emojiData.emoji} + {emojiData.count} + + ))} + + + + )} + + ); +} diff --git a/src/components/rolling/emoji-picker-component.jsx b/src/components/rolling/emoji-picker-component.jsx new file mode 100644 index 0000000..aca5fab --- /dev/null +++ b/src/components/rolling/emoji-picker-component.jsx @@ -0,0 +1,66 @@ +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..a5b8054 --- /dev/null +++ b/src/components/rolling/header-action-buttons.jsx @@ -0,0 +1,56 @@ +import React from "react"; +import styled from "styled-components"; +import EmojiPickerComponent from "./emoji-picker-component"; +import { + RollingHeaderEmojiEditButtonContainer, + RollingHeaderEmojiEditButton, + RollingHeaderEmojiEditButtonIcon, + RollingHeaderEmojiEditButtonText, + PerpendicularLineSecond, + RollingHeaderLinkShareButton, +} from "@/styles/rolling-page-styles"; + +const ShareButtonWrapper = styled.div` + position: relative; +`; + +/** + * 헤더 액션 버튼 컴포넌트 + * 책임: 이모지 추가 버튼과 공유 버튼 렌더링 + */ +export default function HeaderActionButtons({ + isEmojiPickerOpen, + onToggleEmojiPicker, + onCloseEmojiPicker, + onEmojiSelect, + onShareClick, + addEmojiIcon, + shareIcon, + shareModalComponent, // ShareModal 컴포넌트를 props로 받음 +}) { + return ( + + + + + + 추가 + + + + + + + {shareModalComponent} + + + ); +} diff --git a/src/components/rolling/participant-section.jsx b/src/components/rolling/participant-section.jsx new file mode 100644 index 0000000..8ff5470 --- /dev/null +++ b/src/components/rolling/participant-section.jsx @@ -0,0 +1,43 @@ +import React from "react"; +import { + RollingHeaderUserPeopleContainer, + RollingHeaderUserPeopleImages, +} from "@/styles/rolling-page-styles"; +import ProfileImageList from "./profile-image-list"; +import ProfileOverflowBadge from "./profile-overflow-badge"; +import ParticipantStats from "./participant-stats"; +import useProfileImages from "@/hooks/use-profile-images"; + +/** + * 참여자 섹션 컴포넌트 + * 책임: 프로필 이미지와 참여자 통계를 조합하여 표시 + * @param {Array} profiles - 표시할 프로필 목록 + * @param {number} totalCount - 전체 참여자 수 (messageCount) + * @param {number} maxVisible - 최대 표시 개수 + */ +export default function ParticipantSection({ + profiles, + totalCount, + maxVisible = 3, +}) { + const { overflowCount, hasOverflow } = useProfileImages( + totalCount, + profiles, + maxVisible + ); + + return ( + + + {/* 보이는 프로필 이미지들 */} + + + {/* 오버플로우 뱃지 (+N) */} + {hasOverflow && } + + + {/* 참여자 통계 텍스트 - API의 messageCount 사용 */} + + + ); +} diff --git a/src/components/rolling/participant-stats.jsx b/src/components/rolling/participant-stats.jsx new file mode 100644 index 0000000..fc6e2a2 --- /dev/null +++ b/src/components/rolling/participant-stats.jsx @@ -0,0 +1,22 @@ +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..a4e5921 --- /dev/null +++ b/src/components/rolling/profile-image-list.jsx @@ -0,0 +1,24 @@ +import React from "react"; +import { RollingHeaderUserPeopleImage } from "@/styles/rolling-page-styles"; + +/** + * 프로필 이미지 리스트 컴포넌트 + * 책임: 프로필 이미지들을 렌더링 (래퍼 없이 순수 이미지만) + */ +export default function ProfileImageList({ profiles }) { + if (!profiles || profiles.length === 0) { + return null; + } + + return ( + <> + {profiles.map((profile, index) => ( + + ))} + + ); +} diff --git a/src/components/rolling/profile-overflow-badge.jsx b/src/components/rolling/profile-overflow-badge.jsx new file mode 100644 index 0000000..9eb2d39 --- /dev/null +++ b/src/components/rolling/profile-overflow-badge.jsx @@ -0,0 +1,31 @@ +import React from "react"; +import styled from "styled-components"; +import { colors } from "@/styles/colors"; +import { font } from "@/styles/font"; + +const OverflowBadge = styled.div` + width: 28px; + height: 28px; + border-radius: 140px; + border: 1.4px solid ${colors.gray[300]}; + background: #fff; + display: flex; + align-items: center; + justify-content: center; + position: relative; + margin-left: -10px; + ${font.regular12} + color: ${colors.gray[700]}; +`; + +/** + * 프로필 오버플로우 뱃지 컴포넌트 + * 책임: 추가 인원 수를 표시 (+N) + */ +export default function ProfileOverflowBadge({ count }) { + if (count <= 0) { + return null; + } + + return +{count}; +} diff --git a/src/components/rolling/rolling-page-header.jsx b/src/components/rolling/rolling-page-header.jsx new file mode 100644 index 0000000..90a6431 --- /dev/null +++ b/src/components/rolling/rolling-page-header.jsx @@ -0,0 +1,135 @@ +import React, { useState, useEffect } from "react"; +import ShareModal from "@/components/rolling/share-modal"; +import EmojiDisplayList from "@/components/rolling/emoji-display-list"; +import EmojiDropdown from "@/components/rolling/emoji-dropdown"; +import HeaderActionButtons from "@/components/rolling/header-action-buttons"; +import useEmojiManager from "@/hooks/use-emoji-manager"; +import { useReactions } from "@/hooks/use-reactions"; +import { RollingHeaderEmojiContainer } from "@/styles/rolling-page-styles"; + +/** + * 롤링 페이지 헤더 컴포넌트 + * 책임: 전체 헤더 구성 요소 조합 및 상태 관리 + */ +export default function RollingPageHeader({ + recipientId, + topReactions = [], + ArrowDownIcon, + AddEmojiIcon, + ShareIcon, +}) { + // UI 상태 관리 + const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false); + const [isEmojiDropdownOpen, setIsEmojiDropdownOpen] = useState(false); + const [isShareModalOpen, setIsShareModalOpen] = useState(false); + const [allReactions, setAllReactions] = useState([]); + + // API의 topReactions를 이모지 형태로 변환 + const initialEmojis = topReactions.map((reaction) => ({ + emoji: reaction.emoji, + count: reaction.count, + })); + + const { handleEmojiSelect, getSortedEmojis, getTopEmojis } = useEmojiManager(initialEmojis); + + // 리액션 API 훅 + const { reactions, fetchReactions, toggleReaction } = useReactions(recipientId); + + // 드롭다운 열릴 때 전체 리액션 불러오기 + useEffect(() => { + if (isEmojiDropdownOpen && reactions.length === 0) { + fetchReactions(); + } + }, [isEmojiDropdownOpen, reactions.length, fetchReactions]); + + // 전체 리액션 데이터 변환 + useEffect(() => { + if (reactions.length > 0) { + const converted = reactions.map((r) => ({ + emoji: r.emoji, + count: r.count, + })); + setAllReactions(converted); + } + }, [reactions]); + + // 이모지 선택 시 API 호출 + const handleEmojiAdd = async (emoji) => { + try { + await toggleReaction(emoji, "increase"); + handleEmojiSelect(emoji); + } catch (err) { + console.error("이모지 추가 실패", err); + } + }; + + // 이모지 피커 핸들러 + const toggleEmojiPicker = () => { + setIsEmojiPickerOpen(!isEmojiPickerOpen); + }; + + const closeEmojiPicker = () => { + setIsEmojiPickerOpen(false); + }; + + // 이모지 드롭다운 핸들러 + const toggleEmojiDropdown = () => { + setIsEmojiDropdownOpen(!isEmojiDropdownOpen); + }; + + const closeEmojiDropdown = () => { + setIsEmojiDropdownOpen(false); + }; + + // 공유 모달 핸들러 + const openShareModal = () => { + setIsShareModalOpen(true); + }; + + const closeShareModal = () => { + setIsShareModalOpen(false); + }; + + // 정렬된 이모지 및 상위 3개 추출 + const sortedEmojis = getSortedEmojis(); + const topThreeEmojis = getTopEmojis(3); + + // 드롭다운에 표시할 데이터: allReactions가 있으면 사용, 없으면 sortedEmojis 사용 + const dropdownEmojis = allReactions.length > 0 ? allReactions : sortedEmojis; + + // 현재 페이지 URL + const currentUrl = window.location.href; + + return ( + + {/* 상위 3개 이모지 표시 */} +
+ + {dropdownEmojis.length > 0 && ( + + )} +
+ + + {/* 이모지 추가 및 공유 버튼 */} + + } + /> +
+ ); +} diff --git a/src/components/rolling/share-button-group.jsx b/src/components/rolling/share-button-group.jsx new file mode 100644 index 0000000..b6c30c9 --- /dev/null +++ b/src/components/rolling/share-button-group.jsx @@ -0,0 +1,68 @@ +import React from "react"; +import styled from "styled-components"; +import { colors } from "@/styles/colors"; +import { font } from "@/styles/font"; + +const ButtonGroup = styled.div` + position: absolute; + top: calc(100% + 8px); + right: 0; + width: 140px; + height: auto; + display: flex; + flex-direction: column; + background: white; + border-radius: 8px; + border: 1px solid ${colors.gray[300]}; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 1001; + padding: 10px 0px; +`; + +const ShareButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + background: transparent; + width: 100%; + height: 50px; + border: none; + cursor: pointer; + transition: all 0.2s; + ${font.regular16} + color: ${colors.gray[900]}; + + &:hover { + background: ${colors.gray[200]}; + border-color: ${colors.gray[400]}; + } + + &:active { + transform: scale(0.98); + } +`; + +const KakaoButton = styled(ShareButton)` + background: #fee500; + border-color: #fee500; + color: #000000; + + &:hover { + background: #fdd835; + border-color: #fdd835; + } +`; + +/** + * 공유 버튼 그룹 컴포넌트 + * 책임: 공유 방법별 버튼 UI 렌더링 + */ +export default function ShareButtonGroup({ onKakaoShare, onCopyUrl }) { + return ( + + 카카오톡 공유 + URL 복사 + + ); +} diff --git a/src/components/rolling/share-modal.jsx b/src/components/rolling/share-modal.jsx new file mode 100644 index 0000000..74c5fd0 --- /dev/null +++ b/src/components/rolling/share-modal.jsx @@ -0,0 +1,54 @@ +import React from "react"; +import styled from "styled-components"; + +import ShareButtonGroup from "./share-button-group"; +import useKakaoSdk from "@/hooks/use-kakao-sdk"; +import useShareActions from "@/hooks/use-share-actions"; + +const Overlay = styled.div` + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: transparent; + z-index: 999; +`; + +/** + * 공유 모달 컴포넌트 + * 책임: 공유 모달 UI 및 공유 액션 연결 + */ +export default function ShareModal({ isOpen, onClose, shareUrl }) { + // 카카오 SDK 초기화 + useKakaoSdk(); + + // 공유 기능 훅 + const { copyToClipboard, shareToKakao } = useShareActions(); + + // URL 복사 핸들러 + const handleCopyUrl = async () => { + const success = await copyToClipboard(shareUrl); + if (success) { + onClose(); + } + }; + + // 카카오톡 공유 핸들러 + const handleKakaoShare = () => { + shareToKakao(shareUrl); + }; + + if (!isOpen) return null; + + return ( + <> + + e.stopPropagation()} + onKakaoShare={handleKakaoShare} + onCopyUrl={handleCopyUrl} + /> + + ); +} diff --git a/src/hooks/use-cards.js b/src/hooks/use-cards.js new file mode 100644 index 0000000..39ec223 --- /dev/null +++ b/src/hooks/use-cards.js @@ -0,0 +1,29 @@ +import { useMemo } from "react"; + +/** + * 카드 데이터 처리 커스텀 훅 + * 책임: 카드 목록 데이터 가공 및 표시 개수 제한 + */ +export default function useCards(cards, maxVisible = 6) { + const processedData = useMemo(() => { + if (!cards || cards.length === 0) { + return { + visibleCards: [], + totalCount: 0, + hasMore: false, + }; + } + + const totalCount = cards.length; + const hasMore = totalCount > maxVisible; + const visibleCards = cards.slice(0, maxVisible); + + return { + visibleCards, + totalCount, + hasMore, + }; + }, [cards, maxVisible]); + + return processedData; +} diff --git a/src/hooks/use-delete-actions.js b/src/hooks/use-delete-actions.js new file mode 100644 index 0000000..b463770 --- /dev/null +++ b/src/hooks/use-delete-actions.js @@ -0,0 +1,68 @@ +import { useCallback } from "react"; +import { useNavigate } from "react-router"; +import { deleteRecipient } from "@/api/rolling-page-api"; +import { deleteMessage } from "@/api/rolling-page-api"; +import { useToast } from "@/hooks/use-toast"; + +/** + * 삭제 액션 관리 커스텀 훅 + * 책임: 수신자 및 메시지 삭제 로직 처리 + */ +export function useDeleteActions() { + const navigate = useNavigate(); + const showToast = useToast(); + + /** + * 롤링 페이퍼 전체 삭제 + */ + const handleDeleteRecipient = useCallback( + async (recipientId) => { + try { + await deleteRecipient(recipientId); + showToast.delete("롤링 페이퍼가 삭제되었습니다."); + + // 삭제 후 메인 페이지로 이동 + setTimeout(() => { + navigate("/"); + }, 1000); + + return true; + } catch (err) { + console.error("롤링 페이퍼 삭제 실패:", err); + return false; + } + }, + [navigate, showToast], + ); + + /** + * 개별 메시지 삭제 + */ + const handleDeleteMessage = useCallback( + async (messageId, onSuccess) => { + try { + await deleteMessage(messageId); + showToast.delete("메시지가 삭제되었습니다."); + + // 삭제 성공 시 콜백 실행 (목록 갱신 등) + if (onSuccess) { + onSuccess(); + } + + return true; + } catch (err) { + console.error("메시지 삭제 실패:", err); + return false; + } + }, + [showToast], + ); + + return { + handleDeleteRecipient, + handleDeleteMessage, + }; +} + +export default useDeleteActions; + diff --git a/src/hooks/use-dropdown.js b/src/hooks/use-dropdown.js new file mode 100644 index 0000000..c4ed4b7 --- /dev/null +++ b/src/hooks/use-dropdown.js @@ -0,0 +1,16 @@ +import { useState } from "react"; + +const useDropdown = (initialValue) => { + const [value, setValue] = useState(initialValue); + + const handleChange = (e) => { + setValue(e.target.value); + }; + + return { + value, + handleChange, + }; +}; + +export default useDropdown; diff --git a/src/hooks/use-edit-mode.js b/src/hooks/use-edit-mode.js new file mode 100644 index 0000000..1048f93 --- /dev/null +++ b/src/hooks/use-edit-mode.js @@ -0,0 +1,14 @@ +import { useLocation } from "react-router"; + +/** + * 편집 모드 확인 커스텀 훅 + * 책임: URL 경로를 확인하여 편집 모드 여부 판단 + */ +export default function useEditMode() { + const location = useLocation(); + + // URL이 /edit으로 끝나면 편집 모드 + const isEditMode = location.pathname.endsWith("/edit"); + + return isEditMode; +} diff --git a/src/hooks/use-emoji-manager.js b/src/hooks/use-emoji-manager.js new file mode 100644 index 0000000..2d12e4c --- /dev/null +++ b/src/hooks/use-emoji-manager.js @@ -0,0 +1,42 @@ +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-from-input.js b/src/hooks/use-from-input.js new file mode 100644 index 0000000..f1c5118 --- /dev/null +++ b/src/hooks/use-from-input.js @@ -0,0 +1,24 @@ +import { useState } from "react"; + +export default function useFormInput(initialValue = "") { + const [value, setValue] = useState(initialValue); + const [isTouched, setIsTouched] = useState(false); + + // 에러 상태 계산 빙법: 포커스 아웃되었고, 값이 비어있을 때 에러 + const hasError = isTouched && value.trim() === ""; + + const handleChange = (e) => { + setValue(e.target.value); + }; + + const handleBlur = () => { + setIsTouched(true); + }; + + return { + value, + hasError, + handleChange, + handleBlur, + }; +} diff --git a/src/hooks/use-infinite-recipients.js b/src/hooks/use-infinite-recipients.js new file mode 100644 index 0000000..f0dfc87 --- /dev/null +++ b/src/hooks/use-infinite-recipients.js @@ -0,0 +1,81 @@ +import { useState, useCallback } from "react"; +import { getRecipientMessages } from "@/api/rolling-page-api"; + +/** + * 무한 스크롤을 위한 수신자 메시지 조회 커스텀 훅 + * 책임: 메시지 무한 스크롤 데이터 관리 + */ +export function useInfiniteRecipientMessages(recipientId, isEditMode = false) { + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [hasMore, setHasMore] = useState(true); + const [offset, setOffset] = useState(0); + const limit = 6; // 이후 로드할 메시지 개수 + const initialLimit = isEditMode ? 6 : 5; // 뷰어 모드는 추가하기 카드 때문에 5개 + + // 초기 데이터 로드 + const fetchInitialData = useCallback(async () => { + if (!recipientId) return; + + setLoading(true); + setError(null); + setOffset(0); + + try { + const data = await getRecipientMessages(recipientId, { + limit: initialLimit, + offset: 0 + }); + // results는 이미 최신순으로 정렬되어 있음 + setMessages(data.results || []); + setHasMore(data.next !== null); + setOffset(initialLimit); + } catch (err) { + setError(err.message || "메시지를 불러오는데 실패했습니다."); + setHasMore(false); + } finally { + setLoading(false); + } + }, [recipientId, initialLimit]); + + // 더 많은 데이터 로드 (무한 스크롤) + const fetchMoreData = useCallback(async () => { + if (loading || !hasMore) return; + + setLoading(true); + + try { + const data = await getRecipientMessages(recipientId, { limit, offset }); + + // 기존 메시지에 새 메시지 추가 + setMessages((prev) => [...prev, ...(data.results || [])]); + setHasMore(data.next !== null); + setOffset((prev) => prev + limit); + } catch (err) { + setError(err.message || "추가 메시지를 불러오는데 실패했습니다."); + setHasMore(false); + } finally { + setLoading(false); + } + }, [loading, hasMore, offset, recipientId]); + + const refresh = useCallback(() => { + setMessages([]); + setOffset(0); + setHasMore(true); + fetchInitialData(); + }, [fetchInitialData]); + + return { + messages, + loading, + error, + hasMore, + fetchInitialData, + fetchMoreData, + refresh, + }; +} + +export default useInfiniteRecipientMessages; diff --git a/src/hooks/use-kakao-sdk.js b/src/hooks/use-kakao-sdk.js new file mode 100644 index 0000000..b3b4a2e --- /dev/null +++ b/src/hooks/use-kakao-sdk.js @@ -0,0 +1,39 @@ +import { useEffect } from "react"; + +const KAKAO_KEY = import.meta.env.VITE_KAKAO_KEY; + +/** + * 카카오 SDK 초기화 커스텀 훅 + * 책임: 카카오 SDK 스크립트 로드 및 초기화 + */ +export default function useKakaoSdk() { + useEffect(() => { + // 이미 SDK가 로드되어 있으면 초기화만 수행 + if (window.Kakao) { + if (!window.Kakao.isInitialized()) { + window.Kakao.init(KAKAO_KEY); + } + return; + } + + // SDK 스크립트 동적 로드 + const script = document.createElement("script"); + script.src = "https://t1.kakaocdn.net/kakao_js_sdk/2.7.7/kakao.min.js"; + script.integrity = + "sha384-tJkjbtDbvoxO+diRuDtwRO9JXR7pjWnfjfRn5ePUpl7e7RJCxKCwwnfqUAdXh53p"; + script.crossOrigin = "anonymous"; + script.async = true; + + script.onload = () => { + if (window.Kakao && !window.Kakao.isInitialized()) { + window.Kakao.init(KAKAO_KEY); + } + }; + + document.head.appendChild(script); + }, []); + + return { + isKakaoReady: window.Kakao?.isInitialized() || false, + }; +} diff --git a/src/hooks/use-message-form.js b/src/hooks/use-message-form.js new file mode 100644 index 0000000..90dd1de --- /dev/null +++ b/src/hooks/use-message-form.js @@ -0,0 +1,193 @@ +import { useState, useMemo, useEffect } from "react"; +import axios from "axios"; +import { useNavigate, useParams } from "react-router"; + +// API 호스트 루트 +const BASE_URL = "https://rolling-api.vercel.app"; + +const FONT_OPTIONS = [{ value: "Noto Sans", label: "Noto Sans" }]; + +const RELATIONSHIP_OPTIONS = [ + { value: "지인", label: "지인" }, + { value: "친구", label: "친구" }, + { value: "가족", label: "가족" }, + { value: "동료", label: "동료" }, +]; + +// 유효성 검사 훅 (이전과 동일) +const useInput = (initialValue, validate) => { + const [value, setValue] = useState(initialValue); + const [isTouched, setIsTouched] = useState(false); + + const isValid = validate(value); + const hasError = isTouched && !isValid; + + const handleChange = (e) => { + setValue(e.target.value); + }; + + const handleBlur = () => { + setIsTouched(true); + }; + + return { value, isValid, hasError, handleChange, handleBlur }; +}; + +// 메시지 폼 및 Axios API 로직 + +export const useMessageForm = () => { + // 상태 정의 + const { id } = useParams(); + const fromInput = useInput("", (val) => val.trim().length > 0); + const relationshipDropdown = useInput( + RELATIONSHIP_OPTIONS[0].value, + (val) => val.trim().length > 0 + ); + const fontDropdown = useInput( + FONT_OPTIONS[0].value, + (val) => val.trim().length > 0 + ); + const [editorContent, setEditorContent] = useState(""); + + const [selectedProfileImageId, setSelectedProfileImageId] = useState(0); + const [isSubmitting, setIsSubmitting] = useState(false); // POST 요청 상태 + + // 이미지 GET 요청 상태 + const [selectableImages, setSelectableImages] = useState([]); + const [isImagesLoading, setIsImagesLoading] = useState(true); + const [imagesError, setImagesError] = useState(null); + + const isEditorContentValid = editorContent.trim().length > 0; + + const isFormValid = useMemo( + () => + fromInput.isValid && + relationshipDropdown.isValid && + fontDropdown.isValid && + isEditorContentValid, + [ + fromInput.isValid, + relationshipDropdown.isValid, + fontDropdown.isValid, + isEditorContentValid, + ] + ); + + // [GET 요청] 프로필 이미지 목록 가져오기 + + useEffect(() => { + const PROFILE_API_URL = `${BASE_URL}/profile-images/`; + + const fetchImages = async () => { + setIsImagesLoading(true); + setImagesError(null); + + try { + console.log(`[API CALL] GET 요청 시작: ${PROFILE_API_URL}`); + + // axios.get 사용 + const response = await axios.get(PROFILE_API_URL); + const data = response.data; + + // 응답 데이터 (imageUrls 배열)를 변환하여 상태에 저장 + const images = data.imageUrls.map((url, index) => ({ + id: index + 1, + url: url, + })); + + setSelectableImages(images); + // 첫 번째 이미지를 기본값으로 선택 + if (images.length > 0 && selectedProfileImageId === 0) { + setSelectedProfileImageId(images[0].id); + } + } catch (err) { + console.error("프로필 이미지 GET 요청 실패:", err); + // Axios 에러 객체에서 메시지 추출 + const errorMessage = err.response?.data?.message || err.message; + setImagesError(new Error(errorMessage)); + setSelectableImages([]); + } finally { + setIsImagesLoading(false); + } + }; + + fetchImages(); + }, []); + + // [POST 요청] 폼 데이터 전송 (Axios 사용) + const navigate = useNavigate(); + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!isFormValid || isSubmitting) { + fromInput.handleBlur(); + return false; + } + + // 프로필 이미지가 선택되었는지 확인 + if (selectedProfileImageId === 0) { + alert("프로필 이미지를 선택해 주세요."); + return false; + } + + setIsSubmitting(true); + + // 선택된 이미지의 URL을 찾습니다. + const selectedImage = selectableImages.find( + (img) => img.id === selectedProfileImageId + ); + + const formData = { + // API 요구사항에 맞춰 필드 이름을 구성합니다. + sender: fromInput.value, + relationship: relationshipDropdown.value, + font: fontDropdown.value, + content: editorContent, + profileImageURL: selectedImage?.url || null, + createdAt: new Date().toISOString(), + }; + + // 롤링페이퍼 ID가 필요하다면 이 훅을 사용하는 컴포넌트에서 recipientId를 주입해야 합니다. + const POST_API_URL = `${BASE_URL}/20-1/recipients/${id}/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); + + navigate(`/post/${id}`); + return true; + } catch (error) { + console.error("롤링페이퍼 생성 중 API 오류 발생:", error); + // Axios 에러 객체에서 메시지 추출 + const errorMessage = error.response?.data?.message || error.message; + alert(`롤링페이퍼 생성에 실패했습니다: ${errorMessage}`); + return false; + } finally { + setIsSubmitting(false); + } + }; + + // 반환 값 + return { + fromInput, + relationshipDropdown, + fontDropdown, + editorContent, + setEditorContent, + isFormValid, + handleSubmit, + RELATIONSHIP_OPTIONS, + FONT_OPTIONS, + selectedProfileImageId, + handleImageSelect: setSelectedProfileImageId, + selectableImages, + // 이미지 로딩 중이거나 제출 로딩 중이라면 true + isLoading: isImagesLoading || isSubmitting, + error: imagesError, + }; +}; diff --git a/src/hooks/use-profile-image.js b/src/hooks/use-profile-image.js new file mode 100644 index 0000000..dcc8917 --- /dev/null +++ b/src/hooks/use-profile-image.js @@ -0,0 +1,59 @@ +import { useState, useEffect } from "react"; + +import img1 from "@/assets/images/profile-img-01.webp"; +import img2 from "@/assets/images/profile-img-02.webp"; + +export const DEFAULT_IMAGE_ID = 0; + +/* 프로필 이미지 선택 상태와 로직 및 API 통신을 관리하는 커스텀 훅 */ +export const useProfileImage = () => { + const [selectedProfileImageId, setSelectedProfileImageId] = + useState(DEFAULT_IMAGE_ID); + + const [selectableImages, setSelectableImages] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const handleImageSelect = (id) => { + setSelectedProfileImageId(id); + }; + + useEffect(() => { + const API_ENDPOINT = "/api/profile-images"; + + async function fetchImages() { + try { + const MOCK_IMAGES = [ + { id: 1, url: img2 }, + { id: 2, url: img1 }, + { id: 3, url: img2 }, + { id: 4, url: img1 }, + { id: 5, url: img2 }, + { id: 6, url: img1 }, + { id: 7, url: img2 }, + { id: 8, url: img1 }, + { id: 9, url: img2 }, + { id: 10, url: img1 }, + ]; + + setSelectableImages(MOCK_IMAGES); + setError(null); + } catch (err) { + setError(err.message); + console.error("Failed to fetch profile images:", err); + } finally { + setIsLoading(false); + } + } + fetchImages(); + }, []); + + return { + selectedProfileImageId, + handleImageSelect, + selectableImages, + DEFAULT_IMAGE_ID, + isLoading, + error, + }; +}; diff --git a/src/hooks/use-profile-images.js b/src/hooks/use-profile-images.js new file mode 100644 index 0000000..2ea9c10 --- /dev/null +++ b/src/hooks/use-profile-images.js @@ -0,0 +1,34 @@ +import { useMemo } from "react"; + +/** + * 프로필 이미지 데이터 처리 커스텀 훅 + * 책임: 프로필 이미지 데이터 가공 및 오버플로우 계산 + */ +export default function useProfileImages(totalCount, profiles, maxVisible = 3) { + const processedData = useMemo(() => { + if (!profiles || profiles.length === 0) { + return { + visibleProfiles: [], + overflowCount: 0, + totalCount: 0, + hasOverflow: false, + }; + } + + const hasOverflow = totalCount > maxVisible; + + // 오버플로우가 있으면 마지막 자리는 +N 표시용으로 비움 + const visibleCount = 3; + const visibleProfiles = profiles.slice(0, visibleCount); + const overflowCount = hasOverflow ? totalCount - visibleCount : 0; + + return { + visibleProfiles, + overflowCount, + totalCount, + hasOverflow, + }; + }, [totalCount, profiles, maxVisible]); + + return processedData; +} diff --git a/src/hooks/use-reactions.js b/src/hooks/use-reactions.js new file mode 100644 index 0000000..3e6d932 --- /dev/null +++ b/src/hooks/use-reactions.js @@ -0,0 +1,69 @@ +import { useState, useCallback } from "react"; +import { getReactions, addReaction } from "@/api/rolling-page-api"; + +/** + * 리액션 관리 커스텀 훅 + * 책임: 리액션 데이터 관리 및 API 호출 + */ +export function useReactions(recipientId) { + const [reactions, setReactions] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // 모든 리액션 조회 (최대 8개) + const fetchReactions = useCallback(async () => { + if (!recipientId) return; + + setLoading(true); + setError(null); + + try { + const data = await getReactions(recipientId, { limit: 8, offset: 0 }); + setReactions(data.results || []); + } catch (err) { + setError(err.message || "리액션을 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }, [recipientId]); + + // 리액션 추가/감소 + const toggleReaction = useCallback( + async (emoji, type = "increase") => { + if (!recipientId) return; + + try { + const result = await addReaction(recipientId, { emoji, type }); + + // 로컬 상태 업데이트 + setReactions((prev) => { + const existing = prev.find((r) => r.emoji === emoji); + if (existing) { + return prev.map((r) => + r.emoji === emoji ? { ...r, count: result.count } : r, + ); + } else { + return [...prev, result]; + } + }); + + return result; + } catch (err) { + setError(err.message || "리액션 추가에 실패했습니다."); + throw err; + } + }, + [recipientId], + ); + + return { + reactions, + loading, + error, + fetchReactions, + toggleReaction, + }; +} + +export default useReactions; + diff --git a/src/hooks/use-recipients.js b/src/hooks/use-recipients.js new file mode 100644 index 0000000..da78758 --- /dev/null +++ b/src/hooks/use-recipients.js @@ -0,0 +1,51 @@ +import { useState, useEffect, useCallback } from "react"; +import { getRecipientById } from "@/api/rolling-page-api"; + + +/** + * 특정 유저 상세 조회 커스텀 훅 + * 책임: 단일 유저 데이터 관리 및 API 호출 + */ +export function useRecipient(recipientId, autoFetch = true) { + const [recipient, setRecipient] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchRecipient = useCallback(async () => { + if (!recipientId) return; + + setLoading(true); + setError(null); + + try { + const data = await getRecipientById(recipientId); + setRecipient(data); + } catch (err) { + setError(err.message || "수신자 정보를 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }, [recipientId]); + + useEffect(() => { + if (autoFetch && recipientId) { + fetchRecipient(); + } + }, [autoFetch, recipientId, fetchRecipient]); + + const refresh = useCallback(() => { + fetchRecipient(); + }, [fetchRecipient]); + + return { + recipient, + loading, + error, + refresh, + fetchRecipient, + }; +} + + + + diff --git a/src/hooks/use-share-actions.js b/src/hooks/use-share-actions.js new file mode 100644 index 0000000..b9c6bfb --- /dev/null +++ b/src/hooks/use-share-actions.js @@ -0,0 +1,56 @@ +import { useCallback } from "react"; +import { useToast } from "@/hooks/use-toast"; + +/** + * 공유 기능 커스텀 훅 + * 책임: URL 복사 및 카카오톡 공유 비즈니스 로직 처리 + */ +export default function useShareActions() { + const showToast = useToast(); + + /** + * URL을 클립보드에 복사 + */ + const copyToClipboard = useCallback( + async (url) => { + try { + await navigator.clipboard.writeText(url); + showToast.success("URL이 복사되었습니다."); + return true; + } catch (err) { + console.error("URL 복사 실패:", err); + return false; + } + }, + [showToast] + ); + + /** + * 카카오톡으로 공유 + */ + const shareToKakao = useCallback( + (url) => { + if (!window.Kakao) { + return false; + } + + try { + window.Kakao.Share.sendScrap({ + requestUrl: url, + }); + showToast.success("카카오톡으로 공유되었습니다."); + + return true; + } catch (err) { + console.error("카카오톡 공유 실패:", err); + return false; + } + }, + [showToast] + ); + + return { + copyToClipboard, + shareToKakao, + }; +} diff --git a/src/hooks/use-toast.js b/src/hooks/use-toast.js new file mode 100644 index 0000000..0d60146 --- /dev/null +++ b/src/hooks/use-toast.js @@ -0,0 +1,7 @@ +import { createContext, useContext } from "react"; + +export const ToastContext = createContext(null); + +export function useToast() { + return useContext(ToastContext); +} diff --git a/src/main.jsx b/src/main.jsx index 12d36b1..66e917e 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,4 +1,12 @@ import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router"; import App from "./App.jsx"; +import { ToastProvider } from "@/components/common/toast-provider.jsx"; -createRoot(document.getElementById("root")).render(); +createRoot(document.getElementById("root")).render( + + + + + , +); diff --git a/src/pages/list-page.jsx b/src/pages/list-page.jsx new file mode 100644 index 0000000..33dc972 --- /dev/null +++ b/src/pages/list-page.jsx @@ -0,0 +1,93 @@ +import { useEffect, useState } from "react"; +import { getRecipients } from "@/api/list-user-api"; +import { + ButtonLink, + CustomButton, + PageContainer, +} from "@/styles/list-page-styles"; +import { CardList } from "@/components/list/card-list"; + +const baseURL = import.meta.env.VITE_API_BASE_URL; + +export default function ListPage() { + const [likePaper, setLikePaper] = useState([]); + const [recentPaper, setRecentPaper] = useState([]); + const [likeNextUrl, setLikeNextUrl] = useState(null); + const [recentNextUrl, setRecentNextUrl] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchRecipients = async () => { + try { + setIsLoading(true); + const likeData = await getRecipients({ + url: `${baseURL}/recipients/?sort=like`, + }); + const recentData = await getRecipients({ + url: `${baseURL}/recipients/`, + }); + setLikePaper(likeData.results); + setLikeNextUrl(likeData.next); + setRecentPaper(recentData.results); + setRecentNextUrl(recentData.next); + } catch (err) { + console.error("recipients 가져오기 실패:", err); + setError("데이터를 불러오는데 실패했습니다."); + } finally { + setIsLoading(false); + } + }; + fetchRecipients(); + }, []); + + const fetchMoreLike = async () => { + if (!likeNextUrl) return; + try { + const nextData = await getRecipients({ url: likeNextUrl }); + setLikePaper((prev) => [...prev, ...nextData.results]); + setLikeNextUrl(nextData.next); + } catch (err) { + console.error("추가 데이터 로드 실패:", err); + } + }; + + const fetchMoreRecent = async () => { + if (!recentNextUrl) return; + try { + const nextData = await getRecipients({ url: recentNextUrl }); + setRecentPaper((prev) => [...prev, ...nextData.results]); + setRecentNextUrl(nextData.next); + } catch (err) { + console.error("추가 데이터 로드 실패:", err); + } + }; + + if (isLoading) { + return 로딩 중...; + } + + if (error) { + return {error}; + } + + return ( + + + + + 나도 만들어보기 + + + ); +} diff --git a/src/pages/main-page.jsx b/src/pages/main-page.jsx new file mode 100644 index 0000000..baf9e29 --- /dev/null +++ b/src/pages/main-page.jsx @@ -0,0 +1,191 @@ +import styled from "styled-components"; +import { colors } from "@/styles/colors"; +import { font } from "@/styles/font"; +import media from "@/styles/media"; +import Button from "@/components/common/button"; +import { Link } from "react-router"; +import mainVisual01 from "@/assets/images/main-visual-01.webp"; +import mainVisual02 from "@/assets/images/main-visual-02.webp"; + +const Container = styled.div` + max-width: 1200px; + margin: 0 auto; + display: flex; + flex-direction: column; + align-items: center; + gap: 30px; + + ${media.small` + gap: 24px; + `} +`; + +const MainFlexBox = styled.div` + width: 1200px; + height: 324px; + padding: 60px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 20px; + background-color: ${colors.surface}; + background-image: url(${mainVisual01}); + background-size: 60%; + background-repeat: no-repeat; + background-position: right 7px top 60px; + border-radius: 16px; + margin-top: 60px; + + ${media.small` + ${font.regular14} + width: calc(100% - 40px); + height: 352px; + padding: 24px; + margin-left: 20px; + margin-right: 20px; + flex-direction: column; + gap: 12px; + background-position: center bottom 36px; + background-size: 120%; + `} + + ${media.medium` + ${font.regular16}; + width: calc(100% - 48px); + height: 440px; + padding: 40px; + margin-left: 20px; + margin-right: 20px; + flex-direction: column; + background-position: center bottom 36px; + background-size: 100%; + `} + + ${media.large` + ${font.regular18} + `} +`; + +const MainFlexBoxRightPosition = styled.div` + width: 1200px; + height: 324px; + padding: 60px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 20px; + background-color: ${colors.surface}; + background-image: url(${mainVisual02}); + background-size: 60%; + background-repeat: no-repeat; + background-position: left -7px top 60px; + border-radius: 16px; + padding-left: 660px; + + ${media.small` + ${font.regular14} + width: calc(100% - 40px); + height: 352px; + padding: 24px; + margin-left: 20px; + margin-right: 20px; + flex-direction: column; + gap: 12px; + background-position: center bottom 25px; + background-size: 120%; + `} + + ${media.medium` + ${font.regular16}; + width: calc(100% - 48px); + height: 440px; + padding: 40px; + margin-left: 20px; + margin-right: 20px; + flex-direction: column; + background-position: center bottom 25px; + background-size: 100%; + `} +`; + +const PointLabel = styled.div` + width: 80px; + height: 32px; + background-color: ${colors.purple[600]}; + color: #ffffff; + border-radius: 50px; + text-align: center; + line-height: 32px; + ${font.bold14}; +`; + +const MainTitle = styled.p` + ${font.bold24}; + width: 265px; + color: ${colors.gray[900]}; + line-height: 36px; + margin: 0; + + ${media.small` + ${font.bold18}; + width: 100%; + `} + + ${media.medium` + width: 100%; + `} +`; + +const MainTitleSmall = styled.p` + ${font.regular18}; + color: ${colors.gray[500]}; + margin: 0; + + ${media.small` + ${font.regular15}; + `} + + ${media.medium` + ${font.regular18}; + width: 100%; + `} +`; + +const CustomButton = styled(Button)` + width: 286px; + height: 56px; + + ${media.small` + width: calc(100% - 40px); + `} + + ${media.medium` + width: calc(100% - 48px); + `} +`; + +export default function MainPage() { + return ( + + + Point. 01 + + 누구나 손쉽게, 온라인 롤링 페이퍼를 만들 수 있어요 + + 로그인 없이 자유롭게 만들어요. + + + Point. 02 + 서로에게 이모지로 감정을 표현해보세요 + + 롤링 페이퍼에 이모지를 추가할 수 있어요. + + + + + 구경해보기 + + + + ); +} diff --git a/src/pages/message-page.jsx b/src/pages/message-page.jsx new file mode 100644 index 0000000..33a1fa2 --- /dev/null +++ b/src/pages/message-page.jsx @@ -0,0 +1,163 @@ +import React from "react"; +import styled, { css } from "styled-components"; +import { colors } from "@/styles/colors"; +import { font } from "@/styles/font"; +import Button from "@/components/common/button"; +import DropDown from "@/components/message/drop-down"; +import FromInput from "@/components/message/from-input"; +import { useMessageForm } from "@/hooks/use-message-form"; +import RichTextEditor from "@/components/message/reach-text-editor"; +import ProfileImageSelector from "@/components/message/profile-image-selector"; + +export const FormInputStyle = css` + width: 100%; + padding: 12px 16px; + border-radius: 8px; + border: 1px solid ${colors.gray[300]}; + ${font.regular16}; + outline: none; + ${colors.gray[900]}; + background-color: #fff; + + &:focus { + border-color: ${colors.gray[500]}; + } +`; + +export const PageContainer = styled.div` + max-width: 720px; + margin: 0 auto; + padding: 47px 24px 60px 24px; +`; + +export const MessageFormBox = styled.form` + display: flex; + flex-direction: column; + gap: 50px; +`; + +export const FormField = styled.div` + display: flex; + flex-direction: column; + gap: 12px; +`; + +export const FormLabel = styled.label` + ${font.bold24} + line-height: 36px; + letter-spacing: -0.01em; + ${colors.gray[900]}; + margin: 0; + padding: 0; +`; + +export const InputField = styled.input` + ${FormInputStyle} + + &:focus { + border-color: ${colors.gray[500]}; + } +`; + +export const ErrorMessage = styled.p` + color: ${colors.error}; + font-size: 14px; + margin-top: -8px; +`; + +const FullWidthButton = styled(Button)` + width: 100%; + margin-top: 20px; +`; + +function MessagePage() { + const { + fromInput, + relationshipDropdown, + fontDropdown, + editorContent, + setEditorContent, + isFormValid, + handleSubmit, + RELATIONSHIP_OPTIONS, + FONT_OPTIONS, + selectedProfileImageId, + handleImageSelect, + selectableImages, + isLoading, + error, + } = useMessageForm(); + + return ( + + + {/* From. 입력 필드 */} + + From. + + + + {/* 프로필 이미지 선택 */} + + + {/* 상대와의 관계 드롭다운*/} + + 상대와의 관계 + + + + + 내용을 입력해 주세요 + + + + {/* 폰트 선택 드롭다운 */} + + 폰트 선택 + + + + {/* 생성하기 버튼 */} + + 생성하기 + + + + ); +} + +export default MessagePage; diff --git a/src/pages/post-page.jsx b/src/pages/post-page.jsx new file mode 100644 index 0000000..197089b --- /dev/null +++ b/src/pages/post-page.jsx @@ -0,0 +1,147 @@ +import styled from "styled-components"; +import { colors } from "@/styles/colors"; +import { font } from "@/styles/font"; +import media from "@/styles/media"; +import Toggle from "@/components/common/toggle"; +import { useState } from "react"; +import Button from "@/components/common/button"; +import axios from "axios"; + +const Container = styled.div` + max-width: 720px; + margin: 0 auto; + display: flex; + flex-direction: column; + align-items: center; + margin-top: 70px; + gap: 58px; + + ${media.small` + width: calc(100% - 40px); + `} + + ${media.medium` + width: calc(100% - 48px); + `} +`; + +const InputSection = styled.div` + width: 100%; + min-width: 360px; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + gap: 24px; +`; + +const InputSectionTitle = styled.p` + ${font.bold24}; + color: ${colors.gray[900]}; + margin: 0; +`; + +const Input = styled.input` + width: 100%; + min-width: 360px; + height: 50px; + border: 1px solid ${colors.gray[300]}; + border-radius: 8px; + ${font.regular18}; + color: ${colors.gray[500]}; + padding: 12px 16px; +`; + +const CustomButton = styled(Button)` + width: 100%; + height: 56px; +`; + +export default function PostPage() { + const [name, setName] = useState(""); + const [buttonActive, setButtonActive] = useState(false); + + const bgColors = [ + colors.beige[200], + colors.purple[200], + colors.blue[200], + colors.green[200], + ]; + + const [isSelectDiv, setIsSelectDiv] = useState(bgColors[0]); + const [isSelectImg, setIsSelectImg] = useState(null); + + const handleInputName = (e) => { + setName(e.target.value); + + setButtonActive(e.target.value.length > 0 ? true : false); + }; + + const handleSubmit = (e) => { + e.preventDefault(); + + const colorChangeToString = () => { + if (isSelectDiv === "#FFE2AD") { + return "beige"; + } + + if (isSelectDiv === "#ECD9FF") { + return "purple"; + } + + if (isSelectDiv === "#B1E4FF") { + return "blue"; + } + + if (isSelectDiv === "#D0F5C3") { + return "green"; + } + }; + + axios + .post("https://rolling-api.vercel.app/20-1/recipients/", { + name: name, + backgroundColor: colorChangeToString(), + backgroundImageURL: isSelectImg, + }) + .then((response) => { + console.log(response.data); + alert("전송을 성공하였습니다."); + }) + .catch((error) => { + console.error("전송에 실패하였습니다.", error); + }); + + setName(""); + }; + + return ( +
+ + + To. + + + + + 생성하기 + + +
+ ); +} diff --git a/src/pages/rolling-page-edit.jsx b/src/pages/rolling-page-edit.jsx new file mode 100644 index 0000000..8cea62b --- /dev/null +++ b/src/pages/rolling-page-edit.jsx @@ -0,0 +1,58 @@ +import React, { useState } from 'react'; +import { + RollingHeaderContainer, + RollingHeaderUserInfo, + RollingHeaderRightContainer, + PerpendicularLineFirst, + RollingPageContainer, + + +} from "@/styles/rolling-page-styles"; +import RollingPageHeader from "@/pages/rolling-page-head"; +import ParticipantSection from "@/components/rolling/participant-section"; +import ArrowDownIcon from "@/assets/icons/arrow-down.svg"; +import AddEmojiIcon from "@/assets/icons/add-emoji.svg"; +import ShareIcon from "@/assets/icons/share.svg"; +import CardContents from "@/components/rolling/card-contents"; + +export default function RollingPage() { + // TODO: 실제로는 API에서 받아올 데이터 + // 임시 데이터 (나중에 API 호출로 대체) + const [profiles] = useState([ + { id: 1, name: '김철수', profileImageURL: 'https://via.placeholder.com/28' }, + { id: 2, name: '이영희', profileImageURL: 'https://via.placeholder.com/28' }, + { id: 3, name: '박민수', profileImageURL: 'https://via.placeholder.com/28' }, + + ]); + + + return ( + <> + + + To. Ashley Kim + + + + {/* 참여자 프로필 섹션 */} + + + + + {/* 이모지 및 공유 헤더 */} + + + + + + + + + ); +} + + diff --git a/src/pages/rolling-page-head.jsx b/src/pages/rolling-page-head.jsx new file mode 100644 index 0000000..f165398 --- /dev/null +++ b/src/pages/rolling-page-head.jsx @@ -0,0 +1,106 @@ +import React, { useState } from 'react'; +import ShareModal from '@/components/rolling/share-modal'; +import EmojiDisplayList from '@/components/rolling/emoji-display-list'; +import EmojiDropdown from '@/components/rolling/emoji-dropdown'; +import HeaderActionButtons from '@/components/rolling/header-action-buttons'; +import useEmojiManager from '@/hooks/use-emoji-manager'; +import { RollingHeaderImojiContainer } from '@/styles/rolling-page-styles'; + +/** + * 롤링 페이지 헤더 컴포넌트 + * 책임: 전체 헤더 구성 요소 조합 및 상태 관리 + */ +export default function RollingPageHeader({ + ArrowDownIcon, + AddEmojiIcon, + ShareIcon +}) { + // UI 상태 관리 + const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false); + const [isEmojiDropdownOpen, setIsEmojiDropdownOpen] = useState(false); + const [isShareModalOpen, setIsShareModalOpen] = useState(false); + + // 이모지 상태 및 로직 관리 + const initialEmojis = [ + { emoji: '😘', count: 12 }, + { emoji: '😍', count: 8 }, + { emoji: '👍', count: 15 }, + { emoji: '🎉', count: 5 }, + { emoji: '❤️', count: 20 }, + { emoji: '😂', count: 3 }, + { emoji: '🔥', count: 7 } + ]; + + const { handleEmojiSelect, getSortedEmojis, getTopEmojis } = useEmojiManager(initialEmojis); + + // 이모지 피커 핸들러 + const toggleEmojiPicker = () => { + setIsEmojiPickerOpen(!isEmojiPickerOpen); + }; + + const closeEmojiPicker = () => { + setIsEmojiPickerOpen(false); + }; + + // 이모지 드롭다운 핸들러 + const toggleEmojiDropdown = () => { + setIsEmojiDropdownOpen(!isEmojiDropdownOpen); + }; + + const closeEmojiDropdown = () => { + setIsEmojiDropdownOpen(false); + }; + + // 공유 모달 핸들러 + const openShareModal = () => { + setIsShareModalOpen(true); + }; + + const closeShareModal = () => { + setIsShareModalOpen(false); + }; + + // 정렬된 이모지 및 상위 3개 추출 + const sortedEmojis = getSortedEmojis(); + const topThreeEmojis = getTopEmojis(3); + const hasMoreEmojis = sortedEmojis.length > 3; + + // 현재 페이지 URL + const currentUrl = window.location.href; + + return ( + + {/* 상위 3개 이모지 표시 */} + + + {/* 더 많은 이모지가 있을 경우 드롭다운 */} + {hasMoreEmojis && ( + + )} + + {/* 이모지 추가 및 공유 버튼 */} + + } + /> + + ); +} \ No newline at end of file diff --git a/src/pages/rolling-page.jsx b/src/pages/rolling-page.jsx new file mode 100644 index 0000000..8c8c9b4 --- /dev/null +++ b/src/pages/rolling-page.jsx @@ -0,0 +1,126 @@ +import React, { useState } from "react"; +import { useParams } from "react-router"; +import { + RollingHeaderContainer, + RollingHeaderUserInfo, + RollingHeaderRightContainer, + PerpendicularLineFirst, + RollingPageContainer, + CardPageDeleteButton, + CardContainerWrapper, +} from "@/styles/rolling-page-styles"; +import RollingPageHeader from "@/components/rolling/rolling-page-header"; +import ParticipantSection from "@/components/rolling/participant-section"; +import CardContents from "@/components/rolling/card-contents"; +import DeleteConfirmModal from "@/components/rolling/delete-confirm-modal"; +import useEditMode from "@/hooks/use-edit-mode"; +import { useRecipient } from "@/hooks/use-recipients"; +import { useDeleteActions } from "@/hooks/use-delete-actions"; +import ArrowDownIcon from "@/assets/icons/arrow-down.svg"; +import AddEmojiIcon from "@/assets/icons/add-emoji.svg"; +import ShareIcon from "@/assets/icons/share.svg"; + +export default function RollingPage() { + // URL 파라미터에서 recipientId 가져오기 (/post/:id) + const { id } = useParams(); + const recipientId = Number(id); + + // 편집 모드 확인 (URL이 /edit으로 끝나는지) + const isEditMode = useEditMode(); + + // API에서 수신자 데이터 가져오기 + const { recipient, error } = useRecipient(recipientId); + + // 삭제 액션 훅 + const { handleDeleteRecipient } = useDeleteActions(); + + // 페이지 삭제 확인 모달 상태 + const [isDeletePageModalOpen, setIsDeletePageModalOpen] = useState(false); + + // recipientId가 없거나 유효하지 않은 경우 + if (!recipientId || isNaN(recipientId)) { + return
잘못된 페이지 주소입니다.
; + } + + if (error) { + return
에러가 발생했습니다: {error}
; + } + + if (!recipient) { + return
데이터를 불러올 수 없습니다.
; + } + + // recentMessages에서 프로필 데이터 추출 (최신순 3개) + const profiles = + recipient.recentMessages?.map((msg, index) => ({ + id: msg.id || index, + name: msg.sender, + profileImageURL: msg.profileImageURL, + })) || []; + + // 페이지 삭제 핸들러 + const handleOpenDeletePageModal = () => { + setIsDeletePageModalOpen(true); + }; + + const handleCloseDeletePageModal = () => { + setIsDeletePageModalOpen(false); + }; + + const handleConfirmDeletePage = () => { + handleDeleteRecipient(recipientId); + }; + + return ( + <> + + To. {recipient.name} + + + {/* 참여자 프로필 섹션 - messageCount 전달 */} + + + + + {/* 이모지 및 공유 헤더 - topReactions 전달 */} + + + + + + {/* 편집 모드일 때만 페이지 삭제 버튼 표시 */} + + {isEditMode && ( + + 삭제하기 + + )} + + + + + + {/* 페이지 전체 삭제 확인 모달 */} + + + ); +} diff --git a/src/pages/temp-page.jsx b/src/pages/temp-page.jsx new file mode 100644 index 0000000..7be29b1 --- /dev/null +++ b/src/pages/temp-page.jsx @@ -0,0 +1,48 @@ +import Button from "@/components/common/button"; +import { Link } from "react-router"; +import styled from "styled-components"; + +const Contain = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 10px; + margin: 5% auto; +`; + +const CustomButton = styled(Button)` + width: 200px; + padding: 10%; +`; +export default function TempPage() { + return ( + + + 메인 페이지 + + + 리스트 페이지 + + + 롤페 페이지 + + + 롤페 생성 페이지 + + + 롤페 메시지 페이지 + + + + 테스트 페이지 + + + + + toast 테스트 페이지 + + + + ); +} diff --git a/src/pages/test-page.jsx b/src/pages/test-page.jsx deleted file mode 100644 index c03cb51..0000000 --- a/src/pages/test-page.jsx +++ /dev/null @@ -1,56 +0,0 @@ -import styled from "styled-components"; -import { colors } from "@/styles/colors"; -import { font } from "@/styles/font"; -import media from "@/styles/media"; - -const Container = styled.div` - padding: 40px 20px; - max-width: 800px; - margin: 0 auto; -`; - -const Title = styled.h1` - ${font.bold28} - color: ${colors.purple[600]}; - margin-bottom: 40px; -`; - -const ResponsiveBox = styled.div` - padding: 30px; - border-radius: 12px; - text-align: center; - transition: all 0.3s; - - ${media.small` - background: ${colors.blue[200]}; - ${font.regular14} - `} - - ${media.medium` - background: ${colors.green[200]}; - ${font.regular16} - `} - - ${media.large` - background: ${colors.purple[200]}; - ${font.regular18} - `} -`; - -export default function TestPage() { - return ( - - 스타일 테스트 - - 창 크기를 조절해보세요! -
-
- 모바일(~599px): 파란색 배경, 작은 폰트 -
- 태블릿(600~1023px): 초록색 배경, 중간 폰트 -
- 데스크톱(1024px~): 보라색 배경, 큰 폰트 -
-
- ); -} diff --git a/src/styles/global-style.js b/src/styles/global-style.js index 44bbb3d..7af5602 100644 --- a/src/styles/global-style.js +++ b/src/styles/global-style.js @@ -9,14 +9,18 @@ export const GlobalStyle = createGlobalStyle`${css` html, body, + p, + h1, + h2, + h3, #root { margin: 0; padding: 0; + } :root { - --font-family: "Pretendard", -apple-system, BlinkMacSystemFont, system-ui, - Roboto, sans-serif; + --font-family: "Pretendard", -apple-system, BlinkMacSystemFont, system-ui, Roboto, sans-serif; } body { @@ -25,6 +29,13 @@ export const GlobalStyle = createGlobalStyle`${css` -moz-osx-font-smoothing: grayscale; } + button, + input, + textarea, + select { + font-family: var(--font-family); + } + a { text-decoration: none; color: inherit; diff --git a/src/styles/head-nav-style.js b/src/styles/head-nav-style.js new file mode 100644 index 0000000..ebb611d --- /dev/null +++ b/src/styles/head-nav-style.js @@ -0,0 +1,16 @@ +import styled from "styled-components"; +import { colors } from "@/styles/colors"; +import media from "@/styles/media"; + +export const HeadNavContainer = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + height: 65px; + background-color: #fff; + border-bottom: 1px solid ${colors.gray[200]}; + + ${media.small` + display: none; + `} +`; diff --git a/src/styles/list-page-styles.js b/src/styles/list-page-styles.js new file mode 100644 index 0000000..9ffb025 --- /dev/null +++ b/src/styles/list-page-styles.js @@ -0,0 +1,289 @@ +import styled from "styled-components"; +import Button from "@/components/common/button"; +import { Link } from "react-router"; +import { RollingHeaderEmojiContainer } from "@/styles/rolling-page-styles"; +import { font } from "@/styles/font"; +import { colors } from "@/styles/colors"; +import media from "@/styles/media"; +import bgPatternBeige from "@/assets/images/bg-pattern-beige.svg"; +import bgPatternPurple from "@/assets/images/bg-pattern-purple.svg"; +import bgPatternBlue from "@/assets/images/bg-pattern-blue.svg"; +import bgPatternGreen from "@/assets/images/bg-pattern-green.svg"; + +// list-page +export const PageContainer = styled.div` + max-width: 1200px; + margin: 0 auto; +`; + +export const ButtonLink = styled(Link)` + width: 100%; + display: flex; + justify-content: center; + align-items: center; + padding: 24px 0; + margin-top: 40px; + + ${media.medium` + padding: 24px; + `} + + ${media.small` + padding: 24px 20px; + `} +`; + +export const CustomButton = styled(Button)` + padding: 14px 60px; + + ${media.medium` + width: 100%; + `} + + ${media.small` + width: 100%; + `} +`; + +// card-list 비었을 때 +export const EmptySection = styled.div` + width: 100%; + text-align: center; + background-color: rgba(0, 0, 0, 0.05); + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 16px; + box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.08); + ${font.regular20}; + margin-top: 12px; + padding: 80px; + ${media.small` + padding: 80px 30px; + ${font.regular14} + `} +`; + +// card-list +export const Title = styled.h3` + ${font.bold24}; + + ${media.medium` + padding-left: 20px; + `} + + ${media.small` + padding-left: 20px; + `} +`; + +export const ReceiverName = styled.h3` + ${font.bold24}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + color: ${(props) => { + return props.$bgImg ? "white" : "black"; + }}; + + ${media.small` + ${font.bold18}; + `} +`; + +export const CardListLayout = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: start; + margin-top: 50px; + padding: 0 24px; + + ${media.small` + padding: 0; + `} + + ${media.medium` + padding: 0; + `} +`; + +export const SwiperWrapper = styled.div` + width: 100%; + margin-top: 16px; + position: relative; + + .swiper-navigation-icon { + width: 14px; + height: 14px; + } + + .swiper { + position: static; + overflow: hidden; + } + + .swiper-slide { + ${media.medium` + width: 30%; + min-width: 275px; + `} + + ${media.small` + width: 50%; + min-width: 208px; + `} + } + + .swiper-button-next, + .swiper-button-prev { + width: 40px; + height: 40px; + background-color: rgba(255, 255, 255, 0.9); + border: 1px solid #dadcdf; + border-radius: 50%; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.08); + color: #000000; + + ${media.medium` + display: none; + `} + + ${media.small` + display: none; + `} + } + + .swiper-button-next { + right: -20px; + } + + .swiper-button-prev { + left: -20px; + } + + .swiper-button-prev.swiper-button-disabled { + opacity: 0; + cursor: none; + } + + .swiper-button-next.swiper-button-disabled { + opacity: 1; + cursor: pointer; + pointer-events: auto; + } + + &.end-of-list .swiper-button-next.swiper-button-disabled { + opacity: 0; + cursor: none; + } +`; + +export const CardWrapper = styled.div` + width: 100%; + height: 260px; + display: flex; + flex-direction: column; + justify-content: space-between; + + // 배경 color일 때 적용(이미지 같이 내려올 시, 이미지 우선) + background-color: ${(props) => { + if (props.$bgImg) return "transparent"; + const colorMap = { + beige: colors.beige[200], + purple: colors.purple[200], + blue: colors.blue[200], + green: colors.green[200], + }; + return colorMap[props.$bg] || "#ffffff"; + }}; + + // 배경 이미지, null이면 color 적용 + background-image: ${(props) => { + if (props.$bgImg) { + return `linear-gradient(rgba(0,0,0,0.45), rgba(0,0,0,0.45)), url("${props.$bgImg}")`; + } + const patternMap = { + beige: bgPatternBeige, + purple: bgPatternPurple, + blue: bgPatternBlue, + green: bgPatternGreen, + }; + if (props.$bg && patternMap[props.$bg]) { + return `url("${patternMap[props.$bg]}")`; + } + return "none"; + }}; + + background-size: ${(props) => { + if (props.$bgImg) return "cover"; + if (props.$bg === "beige") return "130px"; + if (props.$bg) return "150px"; + return "cover"; + }}; + + background-position: ${(props) => { + if (props.$bgImg) return "center"; + if (props.$bg) return "bottom right"; + return "center"; + }}; + + background-repeat: no-repeat; + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 16px; + box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.08); + padding: 30px 24px 20px; + cursor: pointer; + + ${media.small` + background-size: ${(props) => { + if (props.$bgImg) return "cover"; + if (props.$bg === "beige") return "110px"; + if (props.$bg) return "130px"; + return "cover"; + }}; + `} +`; + +export const CardImgWrapper = styled.div` + display: flex; + align-items: center; + margin-top: 12px; + + img { + width: 28px; + height: 28px; + border: 1.5px solid #ffffff; + border-radius: 50%; + &:not(:first-child) { + margin-left: -14px; + } + } +`; + +export const ProfileCount = styled.div` + display: flex; + justify-content: center; + align-items: center; + border-radius: 30px; + background-color: #ffffff; + margin-left: -14px; + padding: 5px 6px; +`; + +export const WriterCountText = styled.div` + margin-top: 12px; + + color: ${(props) => { + return props.$bgImg ? "white" : "black"; + }}; + + ${font.regular16} span { + ${font.bold16} + } +`; + +export const EmojiWrapper = styled(RollingHeaderEmojiContainer)` + border-top: 1px solid rgba(0, 0, 0, 0.12); + padding-top: 17px; + font-size: 14px; +`; diff --git a/src/styles/message-page.js b/src/styles/message-page.js new file mode 100644 index 0000000..493b0eb --- /dev/null +++ b/src/styles/message-page.js @@ -0,0 +1,150 @@ +import React from "react"; +import styled from "styled-components"; + +const COLOR_PRIMARY = "#954aff"; +const COLOR_GRAY_BORDER = "#ddd"; + +export const PageContainer = styled.div` + display: flex; + justify-content: center; + padding: 80px 0; + min-height: 100vh; + background-color: white; +`; + +export const MessageFormBox = styled.form` + width: 720px; + padding: 40px; + background-color: white; +`; + +export const FormField = styled.div` + margin-bottom: 32px; +`; + +export const FormLabel = styled.label` + display: block; + margin-bottom: 12px; + font-size: 16px; + font-weight: bold; + color: #1c1c1c; +`; + +export const InputField = styled.input` + width: 100%; + padding: 12px 16px; + border: 1px solid ${COLOR_GRAY_BORDER}; + border-radius: 8px; + font-size: 16px; + &:focus { + outline: none; + border-color: ${COLOR_PRIMARY}; + } +`; + +export const ErrorMessage = styled.p` + margin-top: 8px; + font-size: 14px; + color: #ff5050; /* 예시 에러 색상 */ +`; + +export const ProfileWrapper = styled(FormField)` + /* FormField 스타일 상속 */ +`; + +export const ProfileSelectorContainer = styled.div` + display: flex; + align-items: center; +`; + +export const ProfileDefaultBox = styled.div` + width: 56px; + height: 56px; + border-radius: 50%; + background-color: #eee; + display: flex; + justify-content: center; + align-items: center; + overflow: hidden; + margin-right: 16px; + border: 1px solid ${COLOR_GRAY_BORDER}; + cursor: pointer; + & img { + width: 100%; + height: 100%; + object-fit: cover; + } +`; + +export const SelectableImagesList = styled.ul` + display: flex; + list-style: none; + padding: 0; + margin: 0; + overflow-x: auto; + gap: 8px; +`; + +export const SelectableImageItem = styled.li` + width: 40px; + height: 40px; + border-radius: 50%; + overflow: hidden; + cursor: pointer; + border: 2px solid ${(props) => (props.isSelected ? COLOR_PRIMARY : "transparent")}; + transition: border 0.2s; + + & img { + width: 100%; + height: 100%; + object-fit: cover; + } +`; + +export const SelectField = styled.select` + width: 100%; + padding: 12px 16px; + border: 1px solid ${COLOR_GRAY_BORDER}; + border-radius: 8px; + font-size: 16px; + appearance: none; /* 기본 화살표 제거 */ + background-image: url("data:image/svg+xml;utf8,"); /* 커스텀 화살표 SVG */ + background-repeat: no-repeat; + background-position: right 16px center; + &:focus { + outline: none; + border-color: ${COLOR_PRIMARY}; + } +`; + +export const EditorPlaceholder = styled.div` + min-height: 200px; + border: 1px solid ${COLOR_GRAY_BORDER}; + border-radius: 8px; + padding: 16px; + line-height: 1.5; + color: #888; +`; + +export const SubmitButton = styled.button` + width: 100%; + padding: 18px 0; + margin-top: 40px; + background-color: ${COLOR_PRIMARY}; + color: white; + font-size: 18px; + font-weight: bold; + border: none; + border-radius: 12px; + cursor: pointer; + transition: background-color 0.2s; + + &:hover:not(:disabled) { + background-color: #8335f0; + } + + &:disabled { + background-color: #cccccc; /* 비활성화 색상 */ + cursor: not-allowed; + } +`; diff --git a/src/styles/rolling-page-styles.js b/src/styles/rolling-page-styles.js new file mode 100644 index 0000000..de78981 --- /dev/null +++ b/src/styles/rolling-page-styles.js @@ -0,0 +1,570 @@ +import React from "react"; + +import styled from "styled-components"; +import { colors } from "@/styles/colors"; +import { font } from "@/styles/font"; +import ShareIcon from "@/assets/icons/share.svg"; +import media from "@/styles/media"; +import EditIcon from "@/assets/icons/plus.svg"; +import DeleteIcon from "@/assets/icons/deleted.svg"; + +//최상단헤더 컨테이너 +export const RollingHeaderContainer = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + width: 1200px; + margin: 0 auto; + height: 68px; + background-color: rgba(255, 255, 255, 1); + gap: 20px; + + ${media.large` + width: 1200px; + height: 68px; + margin: 0 auto; + padding: 13px 0px; + + gap: 20px; + `} + + ${media.medium` + width: 100%; + height: 68px; + margin: 0; + padding: 13px 20px; + + gap: 10px; + `} + + ${media.small` + flex-direction: column; + align-items: center; + + height: auto; + width: 100%; + padding: 0px; + gap: 0px; + + `} +`; + +//유저 정보 컨테이너 TO. Ashley Kim +export const RollingHeaderUserInfo = styled.div` + display: flex; + align-items: center; + min-width: 227px; + height: 42px; + line-height: 42px; + letter-spacing: -1%; + ${font.bold28} + color: ${colors.gray[800]}; + flex-shrink: 0; + + ${media.medium` + min-width: 150px; + height: 42px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + `} + + ${media.small` + width: 100%; + min-width: auto; + height: auto; + padding: 12px 20px; + border-bottom: 1px solid ${colors.gray[200]}; + `} +`; + +export const RollingHeaderRightContainer = styled.div` + display: flex; + align-items: center; + gap: 20px; + flex-shrink: 1; + min-width: 0; + + ${media.medium` + gap: 8px; + flex-shrink: 1; + min-width: 0; + + `} + + ${media.small` + width: 100%; + padding: 8px 20px; + + + `} +`; + +//유저 이미지 컨테이너 프로필 사진들과, 몇명이 작성중인지 표시 +export const RollingHeaderUserPeopleContainer = styled.div` + width: 228px; + display: flex; + align-items: center; + + ${media.medium` + display: none; + `} + + ${media.small` + display: none; + `} +`; + +//유저 이미지 프로필 사진들 +export const RollingHeaderUserPeopleImages = styled.div` + display: flex; + width: 76px; + height: 28px; + position: relative; + + cursor: pointer; + ${media.medium` + + display: none; + `} + + ${media.small` + + display: none; + `} +`; + +export const RollingHeaderUserPeopleImage = styled.img` + width: 28px; + height: 28px; + border-radius: 140px; + border: 1.4px solid #fff; + position: relative; + margin-left: -10px; +`; + +export const RollingHeaderUserDefaultImage = styled( + RollingHeaderUserPeopleImage +)``; + +//몇명이 작성중인지 +export const RollingHeaderUserPeopleState = styled.div` + width: 160px; + height: 27px; + line-height: 27px; + ${font.bold18} + color: ${colors.gray[900]}; + text-align: center; + ${media.medium` + + display: none; + `} + + ${media.small` + display: none; + `} +`; + +//이모지 컨테이너 드롭박스 포함, 추가 버튼 포함 +export const RollingHeaderEmojiContainer = styled.div` + display: flex; + align-items: center; + gap: 8px; + ${media.small` + gap: 4px; + `} +`; + +export const RollingHeaderEmojiIconContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: auto; + height: auto; + padding: 8px 12px; + text-align: center; + border-radius: 32px; + background: rgba(0, 0, 0, 0.54); + gap: 2px; + + ${media.small` + padding: 6px 10px; + `} +`; + +export const RollingHeaderEmojiIcon = styled.div` + width: 24px; + height: 24px; + color: rgba(255, 255, 255, 1); + + ${media.small` + width: 20px; + height: 24px; + + `} +`; + +export const RollingHeaderEmojiText = styled.span` + ${font.regular16} + color: rgba(255, 255, 255, 1); + + ${media.small` + ${font.regular14} + `} +`; + +export const RollingHeaderEmojiEditButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + width: 88px; + height: 36px; + border-radius: 6px; + background: #fff; + border: 1px solid ${colors.gray[300]}; + cursor: pointer; + ${media.small` + width: 36px; + height: 32px; + `} + &:hover { + background-color: ${colors.gray[100]}; + } +`; + +export const RollingHeaderEmojiEditButtonContainer = styled.div` + display: flex; + align-items: center; + gap: 15px; +`; + +export const RollingHeaderEmojiEditButtonIcon = styled.img` + width: 20px; + height: 20px; +`; + +export const RollingHeaderEmojiEditButtonText = styled.span` + ${font.regular16} + color: ${colors.gray[900]}; + ${media.small` + display: none; + `} +`; + +export const RollingHeaderLinkShareButton = styled.div` + width: 56px; + height: 36px; + border-radius: 6px; + background-image: url("${ShareIcon}"); + background-size: 24px 24px; + background-repeat: no-repeat; + background-position: center; + padding: 12px 32px; + cursor: pointer; + border: 1px solid ${colors.gray[300]}; + &:hover { + background-color: ${colors.gray[100]}; + } + ${media.small` + width: 36px; + height: 32px; + background-size: 20px 20px; + padding: 8px 8px; + + `} +`; + +export const RollingHeaderArrowDown = styled.img` + width: 24px; + height: 24px; + cursor: pointer; +`; + +export const PerpendicularLine = styled.div` + border-left: 1px solid ${colors.gray[200]}; + height: 28px; +`; + +export const PerpendicularLineFirst = styled(PerpendicularLine)` + ${media.medium` + display: none; + `} + + ${media.small` + display: none; + `} +`; + +export const PerpendicularLineSecond = styled(PerpendicularLine)``; + +const RollingPageWrapper = ({ + $backgroundcolor, + $backgroundimage, + ...rest +}) => { + return React.createElement("div", { + $backgroundcolor, + $backgroundimage, + ...rest, + }); +}; +export const RollingPageContainer = styled(RollingPageWrapper)` + display: flex; + justify-content: center; + background-color: ${(props) => props.$backgroundcolor || colors.blue[100]}; + background-image: ${(props) => + props.$backgroundimage ? `url(${props.$backgroundimage})` : "none"}; + background-size: cover; + background-repeat: no-repeat; + width: 100%; + min-height: 100vh; + margin: 0 auto; + padding: 63px 216px 113px 216px; + gap: 11px; + + ${media.medium` + padding: 5% 2%; + `} + + ${media.small` + padding: 63px 20px 113px 20px; + `} +`; + +export const CardContainerWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 11px; + + & > :first-child { + align-self: flex-end; + } + + ${media.medium` + gap: 7px; + width: 100%; + `} + + ${media.small` + gap: 4px; + `} +`; + +export const CardContainer = styled.div` + display: grid; + width: 100%; + height: 100%; + grid-template-columns: repeat(3, 1fr); + grid-template-rows: repeat(2, 1fr); + + gap: 20px; + ${media.medium` + grid-template-columns: repeat(2, 1fr); + grid-template-rows: repeat(3, 1fr); + gap: 16px; + `} + ${media.small` + grid-template-columns: repeat(1, 1fr); + grid-template-rows: repeat(6, 1fr); + gap: 16px; + + `} +`; + +export const Card = styled.div` + display: flex; + justify-content: center; + align-items: center; + width: 384px; + height: 280px; + border-radius: 16px; + background-color: #fff; + position: relative; + + ${media.medium` + width: 100%; + height: auto; + `} + + ${media.small` + width: 100%; + `} +`; + +export const CardEditButton = styled.button` + width: 56px; + height: 56px; + background-image: url("${EditIcon}"); + background-color: ${colors.gray[500]}; + border-radius: 100px; + border: none; + padding: 20px; + background-size: 24px 24px; + background-repeat: no-repeat; + background-position: center; + cursor: pointer; + &:hover { + background-color: ${colors.gray[400]}; + color: ${colors.gray[100]}; + } +`; + +export const CardContentContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + width: 100%; + height: 100%; + gap: 15px; + padding: 28px 24px; +`; + +export const CardContentStatus = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + height: auto; + gap: 14px; + border-bottom: 1px solid ${colors.gray[200]}; + padding-bottom: 16px; +`; + +export const CardContentStatusContainer = styled.div` + display: flex; + align-items: center; + gap: 14px; +`; + +export const CardContentStatusProfileContainer = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; +`; + +export const CardContentFrom = styled.div` + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; +`; + +export const CardContentStatusProfileImage = styled.img` + width: 56px; + height: 56px; + border-radius: 100px; + border: 1px solid ${colors.gray[300]}; +`; + +export const CardContentStatusProfileName = styled.div` + ${font.regular16} + color: ${colors.gray[900]}; +`; + +const relationshipColors = { + friend: colors.blue[100], + family: colors.green[100], + colleague: colors.purple[100], + acquaintance: colors.beige[100], +}; + +const relationshipTextColors = { + friend: colors.blue[500], + family: colors.green[500], + colleague: colors.purple[600], + acquaintance: colors.beige[500], +}; + +export const CardContentStatusRelationship = styled.div` + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 8px; + height: 20px; + border-radius: 4px; + ${font.regular14} + color: ${(props) => + relationshipTextColors[props.$relationship] || colors.gray[500]}; + + background-color: ${(props) => + relationshipColors[props.$relationship] || colors.gray[500]}; +`; + +export const CardContentText = styled.div` + width: 100%; + height: 40%; + ${font.regular18} + color: ${colors.gray[600]}; + cursor: pointer; + line-height: 28px; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; +`; + +export const CardContentDate = styled.div` + ${font.regular12} + color: ${colors.gray[400]}; +`; + +export const CardContentDeleteButton = styled.div` + width: 40px; + height: 40px; + background-image: url("${DeleteIcon}"); + border-radius: 6px; + border: 1px solid ${colors.gray[300]}; + padding: 20px; + background-size: 24px 24px; + background-repeat: no-repeat; + background-position: center; + cursor: pointer; + &:hover { + background-color: ${colors.gray[200]}; + color: ${colors.gray[100]}; + } +`; + +export const CardPageDeleteButton = styled.div` + display: flex; + justify-content: center; + align-items: center; + width: 92px; + height: 39px; + border-radius: 6px; + border: 1px solid ${colors.gray[300]}; + background-color: ${colors.purple[600]}; + cursor: pointer; + color: #fff; + padding: 7px 16px; + ${font.regular16} + text-align: center; + + ${media.medium` + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + width: calc(100% - 40px); + height: 56px; + padding: 12px 16px; + z-index: 1003; + box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.2); + `} + + ${media.small` + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + width: calc(100% - 40px); + height: 56px; + padding: 12px 16px; + z-index: 1003; + box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.2); + `} +`;