diff --git a/index.html b/index.html index aa094a9..9f730d2 100644 --- a/index.html +++ b/index.html @@ -7,7 +7,7 @@ - + diff --git a/package-lock.json b/package-lock.json index 6abc74b..e48c004 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "dompurify": "^3.2.6", "emoji-picker-react": "^4.12.2", "eslint-plugin-react": "^7.37.5", + "html-truncate": "^1.2.2", "lexical": "^0.32.1", "react": "^19.1.0", "react-colorful": "^5.6.1", @@ -3809,6 +3810,14 @@ "node": ">= 0.4" } }, + "node_modules/html-truncate": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/html-truncate/-/html-truncate-1.2.2.tgz", + "integrity": "sha512-oiXb65RDxSYY8eXJKJiKHHpI7LyQbMG8LbSuvvWLLxAKNywJ7aaq0M1ynj2QJs0odQNggEEg/dTFVtH3lG0ClQ==", + "engines": { + "node": "*" + } + }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", diff --git a/package.json b/package.json index 732e525..12176fd 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "dompurify": "^3.2.6", "emoji-picker-react": "^4.12.2", "eslint-plugin-react": "^7.37.5", + "html-truncate": "^1.2.2", "lexical": "^0.32.1", "react": "^19.1.0", "react-colorful": "^5.6.1", diff --git a/public/images/image_opengraph_narrow.png b/public/images/image_opengraph_narrow.png new file mode 100644 index 0000000..c778367 Binary files /dev/null and b/public/images/image_opengraph_narrow.png differ diff --git a/public/images/image_opengraph_wide.png b/public/images/image_opengraph_wide.png new file mode 100644 index 0000000..a233a64 Binary files /dev/null and b/public/images/image_opengraph_wide.png differ diff --git a/src/apis/recipientsApi.js b/src/apis/recipientsApi.js index 3e3f354..7d711bc 100644 --- a/src/apis/recipientsApi.js +++ b/src/apis/recipientsApi.js @@ -13,7 +13,7 @@ export const TEAM = '2'; * @param {Object} [params] - 페이지네이션 옵션 객체 * @param {number} [params.limit=20] - **가져올 개수** (page size) * @param {number} [params.offset=0] - **시작 인덱스** (0부터) - * + * @param {boolean} [params.sortLike=false] - `true`면 reactionCount 순으로 정렬 (`sort=like`) * @returns {Promise<{ * count: number, // 총 Recipient 개수 * next: string|null, // 다음 페이지 URL (없으면 null) @@ -31,9 +31,9 @@ export const TEAM = '2'; * }> * }>} */ -export const listRecipients = ({ limit = 20, offset = 0 } = {}) => +export const listRecipients = ({ limit = 20, offset = 0, sortLike = false } = {}) => httpClient.get(`/${TEAM}/recipients/`, { - params: { limit, offset }, + params: { limit, offset, ...(sortLike && { sort: 'like' }) }, }); /** diff --git a/src/assets/editorIcons/icon_redo_active.svg b/src/assets/editorIcons/icon_redo_active.svg new file mode 100644 index 0000000..399fb2a --- /dev/null +++ b/src/assets/editorIcons/icon_redo_active.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/editorIcons/icon_redo_inactive.svg b/src/assets/editorIcons/icon_redo_inactive.svg new file mode 100644 index 0000000..a6792e3 --- /dev/null +++ b/src/assets/editorIcons/icon_redo_inactive.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/editorIcons/icon_undo_active.svg b/src/assets/editorIcons/icon_undo_active.svg new file mode 100644 index 0000000..a41f07f --- /dev/null +++ b/src/assets/editorIcons/icon_undo_active.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/editorIcons/icon_undo_inactive.svg b/src/assets/editorIcons/icon_undo_inactive.svg new file mode 100644 index 0000000..70323b8 --- /dev/null +++ b/src/assets/editorIcons/icon_undo_inactive.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/fonts/NanumMyeongjo.ttf b/src/assets/fonts/NanumMyeongjo.ttf similarity index 100% rename from public/fonts/NanumMyeongjo.ttf rename to src/assets/fonts/NanumMyeongjo.ttf diff --git a/public/fonts/NanumSonPyeonJiCe.ttf b/src/assets/fonts/NanumSonPyeonji.ttf similarity index 100% rename from public/fonts/NanumSonPyeonJiCe.ttf rename to src/assets/fonts/NanumSonPyeonji.ttf diff --git a/public/fonts/NotoSans.ttf b/src/assets/fonts/NotoSans.ttf similarity index 100% rename from public/fonts/NotoSans.ttf rename to src/assets/fonts/NotoSans.ttf diff --git a/src/assets/styles/_font.scss b/src/assets/styles/_font.scss index ffee1fe..7363e87 100644 --- a/src/assets/styles/_font.scss +++ b/src/assets/styles/_font.scss @@ -2,7 +2,7 @@ @font-face { font-family: Pretendard; - src: url('../fonts/Pretendard-Regular.woff2') format('woff2'); + src: url('@/assets/fonts/Pretendard-Regular.woff2') format('woff2'); font-weight: 400; font-style: normal; font-display: swap; @@ -10,7 +10,7 @@ @font-face { font-family: Pretendard; - src: url('../fonts/Pretendard-Bold.woff2') format('woff2'); + src: url('@/assets/fonts/Pretendard-Bold.woff2') format('woff2'); font-weight: 700; font-style: normal; font-display: swap; @@ -19,7 +19,7 @@ /* Noto Sans */ @font-face { font-family: 'Noto Sans'; - src: url('/fonts/NotoSans.ttf') format('truetype'); + src: url('@/assets/fonts/NotoSans.ttf') format('truetype'); font-weight: 400; font-style: normal; font-display: swap; @@ -28,7 +28,7 @@ /* Nanum Myeongjo */ @font-face { font-family: 'Nanum Myeongjo'; - src: url('/fonts/NanumMyeongjo.ttf') format('truetype'); + src: url('@/assets/fonts/NanumMyeongjo.ttf') format('truetype'); font-weight: 400; font-style: normal; font-display: swap; @@ -37,7 +37,7 @@ /* Nanum Son Pyeonji (손편지체) */ @font-face { font-family: 'Nanum Son Pyeonji'; - src: url('/fonts/NanumSonPyeonji.ttf') format('truetype'); + src: url('@/assets/fonts/NanumSonPyeonji.ttf') format('truetype'); font-weight: 400; font-style: normal; font-display: swap; diff --git a/src/assets/styles/_reset.scss b/src/assets/styles/_reset.scss index 74ca749..997a1a3 100644 --- a/src/assets/styles/_reset.scss +++ b/src/assets/styles/_reset.scss @@ -116,3 +116,7 @@ table { hr { margin: 0; } + +a { + text-decoration: none; +} diff --git a/src/components/Button/ArrowButton.jsx b/src/components/Button/ArrowButton.jsx index f51c0d1..91975d4 100644 --- a/src/components/Button/ArrowButton.jsx +++ b/src/components/Button/ArrowButton.jsx @@ -3,11 +3,12 @@ import styles from './ArrowButton.module.scss'; import arrowLeft from '@/assets/icons/arrow_left.svg'; import arrowRight from '@/assets/icons/arrow_right.svg'; -function ArrowButton({ direction = 'right', onClick, disabled = false }) { +function ArrowButton({ direction = 'right', onClick, enabled = true }) { const icon = direction === 'left' ? arrowLeft : arrowRight; + const isDisabled = !enabled; return ( - ); diff --git a/src/components/Button/Button.jsx b/src/components/Button/Button.jsx index f8a9bf7..9773684 100644 --- a/src/components/Button/Button.jsx +++ b/src/components/Button/Button.jsx @@ -3,41 +3,114 @@ import styles from './Button.module.scss'; function Button({ children, - state = 'enabled', + enabled = true, size = 'large', variant = 'primary', iconOnly = false, + icon, // 새로 추가된 icon prop + onClick, + className, // 간단하게 변경 + ...rest // 나머지 모든 props 받기 }) { - const isDisabled = state === 'disabled'; - - let className = ''; - - // 1. 유효한 사이즈 검사 (Outlined에서만 필요) - const allowedSizes = ['56', '40', '36', '28', 'small', 'large']; - if (!allowedSizes.includes(size)) { - throw new Error(` 지원하지 않는 size: ${size}`); - } - - if (variant === 'secondary') { - className = isDisabled ? styles['secondary-small-disabled'] : styles['secondary-small']; - } else if (variant === 'outlined') { - // 아이콘 전용 (예외 케이스) - if (iconOnly && size === '36') { - className = styles['outlined-icon-36']; - } else { - const key = `outlined-${size}${isDisabled ? '-disabled' : ''}`; - className = styles[key]; + const isDisabled = !enabled; + + // 버튼 사이즈에 따른 아이콘 크기 자동 결정 + const getIconSize = () => { + if (size === '36') return 24; + if (size === '40') return 24; + return 24; // 기본값 + }; + + // 실제 스펙 기반으로 새로 구현 + const getButtonClassName = () => { + // 스펙 1: Primary 버튼 (4가지) + if (variant === 'primary') { + // 1-1. Primary Large (280×56) - 구경해보기, 나도 만들어보기 + if (size === 'large') { + return isDisabled ? styles['primary-large-disabled'] : styles['primary-large']; + } + + // 1-2. Primary Stretch (100%×56) - 생성하기 + if (size === 'stretch') { + return isDisabled ? styles['primary-stretch-disabled'] : styles['primary-stretch']; + } + + // 1-3. Primary Medium (120×40) - 모달 확인 + if (size === 'medium') { + return isDisabled ? styles['primary-medium-disabled'] : styles['primary-medium']; + } + + // 1-4. Primary Small (92×39) - 삭제하기 + if (size === 'small') { + return isDisabled ? styles['primary-small-disabled'] : styles['primary-small']; + } + + throw new Error(`Primary 버튼 지원 사이즈: large, stretch, medium, small. 현재: ${size}`); + } + + // 스펙 2: Secondary 버튼 (2가지) + if (variant === 'secondary') { + // Secondary Small (92×39) - Primary Small과 같은 크기, 다른 스타일 + if (size === 'small') { + return isDisabled ? styles['secondary-small-disabled'] : styles['secondary-small']; + } + + // Secondary Stretch (100%×39) - Secondary Small의 너비만 늘린 버전 + if (size === 'stretch') { + return isDisabled ? styles['secondary-stretch-disabled'] : styles['secondary-stretch']; + } + + throw new Error(`Secondary 버튼 지원 사이즈: small, stretch. 현재: ${size}`); } - } else { - // primary 버튼 - className = - size === 'small' - ? styles[`${state}-small`] // enabled-small 등 - : styles[state]; // enabled, disabled - } + + // 스펙 3: Outlined 버튼 (4가지) + if (variant === 'outlined') { + // 2-1. Outlined 40px 텍스트 (157×40) - 롤링 페이퍼 만들기 + if (size === '40' && !iconOnly) { + return isDisabled ? styles['outlined-40-text-disabled'] : styles['outlined-40-text']; + } + + // 2-2. Outlined 36px 아이콘+텍스트 (89×36) - 이모티콘+추가 + if (size === '36' && !iconOnly) { + return isDisabled ? styles['outlined-36-text-disabled'] : styles['outlined-36-text']; + } + + // 2-3. Outlined 36px 아이콘만 (56×36) - 헤더 아이콘들 + if (size === '36' && iconOnly) { + return isDisabled ? styles['outlined-36-icon-disabled'] : styles['outlined-36-icon']; + } + + // 2-4. Outlined 40px 아이콘만 (40×40) - 휴지통 삭제 + if (size === '40' && iconOnly) { + return isDisabled ? styles['outlined-40-icon-disabled'] : styles['outlined-40-icon']; + } + + throw new Error( + `Outlined 버튼 지원 조합: 40+텍스트, 36+텍스트, 36+아이콘만, 40+아이콘만. 현재: ${size}, iconOnly=${iconOnly}`, + ); + } + + // 지원하지 않는 variant + throw new Error( + `지원하지 않는 variant: ${variant}. 지원 variant: primary, secondary, outlined`, + ); + }; + + const baseClassName = getButtonClassName(); + const iconSize = getIconSize(); + + // 커스텀 className과 합치기 + const finalClassName = className ? `${baseClassName} ${className}` : baseClassName; return ( - ); diff --git a/src/components/Button/Button.module.scss b/src/components/Button/Button.module.scss index c16f847..c885180 100644 --- a/src/components/Button/Button.module.scss +++ b/src/components/Button/Button.module.scss @@ -1,6 +1,13 @@ -.enabled { +/* ===== 실제 스펙 기반 새 CSS ===== */ + +/** 1. Primary 버튼들 (4가지) **/ + +/* 1-1. Primary Large (280×56) - 구경해보기, 나도 만들어보기 */ +.primary-large { background-color: var(--color-purple-600); color: var(--color-white); + width: 280px; + height: 56px; padding: 14px 24px; border: none; border-radius: 12px; @@ -9,46 +16,106 @@ font-family: var(--font-family-base); letter-spacing: -0.18px; line-height: 28px; - min-width: 208px; - max-width: 208px; text-align: center; cursor: pointer; transition: background-color 0.5s ease; + display: flex; + align-items: center; + justify-content: center; } -.enabled:hover { +.primary-large:hover { background-color: var(--color-purple-700); } -.enabled:active { +.primary-large:active { background-color: var(--color-purple-800); } -.enabled:focus { +.primary-large:focus { border: 2px solid var(--color-purple-900); } -.disabled { +.primary-large-disabled { background-color: var(--color-gray-300); color: var(--color-white); + width: 280px; + height: 56px; padding: 14px 24px; border: none; border-radius: 12px; font-size: var(--font-size-18); font-weight: var(--font-weight-bold); font-family: var(--font-family-base); - line-height: 28px; letter-spacing: -0.18px; - min-width: 208px; - max-width: 208px; + line-height: 28px; + text-align: center; cursor: not-allowed; + display: flex; + align-items: center; + justify-content: center; } -/** 작은버전 Primary Button **/ +/* 1-2. Primary Stretch (100%×56) - 생성하기 */ +.primary-stretch { + background-color: var(--color-purple-600); + color: var(--color-white); + width: 100%; + height: 56px; + padding: 14px 24px; + border: none; + border-radius: 12px; + font-size: var(--font-size-18); + font-weight: var(--font-weight-bold); + font-family: var(--font-family-base); + letter-spacing: -0.18px; + line-height: 28px; + text-align: center; + cursor: pointer; + transition: background-color 0.5s ease; + display: flex; + align-items: center; + justify-content: center; +} -.enabled-small { +.primary-stretch:hover { + background-color: var(--color-purple-700); +} + +.primary-stretch:active { + background-color: var(--color-purple-800); +} + +.primary-stretch:focus { + border: 2px solid var(--color-purple-900); +} + +.primary-stretch-disabled { + background-color: var(--color-gray-300); + color: var(--color-white); + width: 100%; + height: 56px; + padding: 14px 24px; + border: none; + border-radius: 12px; + font-size: var(--font-size-18); + font-weight: var(--font-weight-bold); + font-family: var(--font-family-base); + letter-spacing: -0.18px; + line-height: 28px; + text-align: center; + cursor: not-allowed; + display: flex; + align-items: center; + justify-content: center; +} + +/* 1-3. Primary Medium (120×40) - 모달 확인 */ +.primary-medium { background-color: var(--color-purple-600); color: var(--color-white); + width: 120px; + height: 40px; padding: 7px 16px; border: none; border-radius: 6px; @@ -57,354 +124,436 @@ font-family: var(--font-family-base); letter-spacing: -0.16px; line-height: 26px; - min-width: 122px; - max-width: 122px; text-align: center; cursor: pointer; transition: background-color 0.5s ease; + display: flex; + align-items: center; + justify-content: center; } -.enabled-small:hover { +.primary-medium:hover { background-color: var(--color-purple-700); } -.enabled-small:active { +.primary-medium:active { background-color: var(--color-purple-800); } -.enabled-small:focus { +.primary-medium:focus { border: 2px solid var(--color-purple-900); } -.disabled-small { +.primary-medium-disabled { background-color: var(--color-gray-300); color: var(--color-white); + width: 120px; + height: 40px; padding: 7px 16px; border: none; border-radius: 6px; font-size: var(--font-size-16); font-weight: var(--font-weight-regular); font-family: var(--font-family-base); + letter-spacing: -0.16px; line-height: 26px; + text-align: center; + cursor: not-allowed; + display: flex; + align-items: center; + justify-content: center; +} + +/* 1-4. Primary Small (92×39) - 삭제하기 */ +.primary-small { + background-color: var(--color-purple-600); + color: var(--color-white); + width: 92px; + height: 39px; + padding: 7px 16px; + border: none; + border-radius: 6px; + font-size: var(--font-size-16); + font-weight: var(--font-weight-regular); + font-family: var(--font-family-base); + letter-spacing: -0.16px; + line-height: 26px; + text-align: center; + cursor: pointer; + transition: background-color 0.5s ease; + display: flex; + align-items: center; + justify-content: center; +} + +.primary-small:hover { + background-color: var(--color-purple-700); +} + +.primary-small:active { + background-color: var(--color-purple-800); +} + +.primary-small:focus { + border: 2px solid var(--color-purple-900); +} + +.primary-small-disabled { + background-color: var(--color-gray-300); + color: var(--color-white); + width: 92px; + height: 39px; + padding: 7px 16px; + border: none; + border-radius: 6px; + font-size: var(--font-size-16); + font-weight: var(--font-weight-regular); + font-family: var(--font-family-base); letter-spacing: -0.16px; - min-width: 122px; - max-width: 122px; + line-height: 26px; + text-align: center; cursor: not-allowed; + display: flex; + align-items: center; + justify-content: center; } -/** Secondary Button **/ +/** 2. Secondary 버튼들 (2가지) **/ +/* 2-1. Secondary Small (92×39) - Primary Small과 같은 크기, 다른 스타일 */ .secondary-small { background-color: var(--color-white); color: var(--color-purple-600); border: 1px solid var(--color-purple-600); + width: 92px; + height: 39px; padding: 7px 16px; border-radius: 6px; font-size: var(--font-size-16); font-weight: var(--font-weight-regular); font-family: var(--font-family-base); - line-height: 26px; letter-spacing: -0.16px; - min-width: 122px; - max-width: 122px; + line-height: 26px; + text-align: center; cursor: pointer; transition: background-color 0.2s ease; + display: flex; + align-items: center; + justify-content: center; } .secondary-small:hover { - border: 1px solid var(--color-purple-700); background-color: var(--color-purple-100); } .secondary-small:active { - border: 1px solid var(--color-purple-800); background-color: var(--color-purple-100); } .secondary-small:focus { border: 1px solid var(--color-purple-800); - background-color: var(--color-purple-100); } .secondary-small-disabled { background-color: var(--color-gray-300); color: var(--color-white); - border: none; - cursor: not-allowed; + border: 1px solid var(--color-gray-300); + width: 92px; + height: 39px; padding: 7px 16px; border-radius: 6px; font-size: var(--font-size-16); font-weight: var(--font-weight-regular); font-family: var(--font-family-base); - line-height: 26px; letter-spacing: -0.16px; - min-width: 122px; - max-width: 122px; + line-height: 26px; + text-align: center; + cursor: not-allowed; + display: flex; + align-items: center; + justify-content: center; } -/** OutLined **/ - -.outlined-56 { +/* 2-2. Secondary Stretch (100%×39) - Secondary Small의 너비만 늘린 버전 */ +.secondary-stretch { background-color: var(--color-white); - color: var(--color-gray-900); - border: 1px solid var(--color-gray-300); - padding: 14px 24px; /* 위아래 14px → 총 높이 약 56px */ - border-radius: 12px; - font-size: var(--font-size-18); - font-weight: var(--font-weight-bold); + color: var(--color-purple-600); + border: 1px solid var(--color-purple-600); + width: 100%; /* 너비만 100%로 변경 */ + height: 39px; /* Secondary Small과 동일한 높이 유지 */ + padding: 7px 16px; + border-radius: 6px; + font-size: var(--font-size-16); + font-weight: var(--font-weight-regular); font-family: var(--font-family-base); - line-height: 28px; - letter-spacing: -0.18px; - min-width: 208px; - max-width: 208px; + letter-spacing: -0.16px; + line-height: 26px; text-align: center; cursor: pointer; transition: background-color 0.2s ease; + display: flex; + align-items: center; + justify-content: center; } -.outlined-56:hover { - background-color: var(--color-gray-100); +.secondary-stretch:hover { + background-color: var(--color-purple-100); } -.outlined-56:active { - background-color: var(--color-gray-100); +.secondary-stretch:active { + background-color: var(--color-purple-100); } -.outlined-56:focus { - border: 1px solid var(--color-gray-500); +.secondary-stretch:focus { + border: 1px solid var(--color-purple-800); } -.outlined-56-disabled { +.secondary-stretch-disabled { background-color: var(--color-gray-300); color: var(--color-white); border: 1px solid var(--color-gray-300); - cursor: not-allowed; - padding: 14px 24px; - border-radius: 12px; - font-size: var(--font-size-18); - font-weight: var(--font-weight-bold); + width: 100%; /* 너비만 100%로 변경 */ + height: 39px; /* Secondary Small과 동일한 높이 유지 */ + padding: 7px 16px; + border-radius: 6px; + font-size: var(--font-size-16); + font-weight: var(--font-weight-regular); font-family: var(--font-family-base); - line-height: 28px; - letter-spacing: -0.18px; - min-width: 208px; - max-width: 208px; + letter-spacing: -0.16px; + line-height: 26px; text-align: center; + cursor: not-allowed; + display: flex; + align-items: center; + justify-content: center; } -/** 40 OutLined **/ -.outlined-40 { +/** 3. Outlined 버튼들 (4가지) **/ + +/* 3-1. Outlined 40px 텍스트 (157×40) - 롤링 페이퍼 만들기 */ +.outlined-40-text { background-color: var(--color-white); color: var(--color-gray-900); border: 1px solid var(--color-gray-300); + width: 157px; + height: 40px; padding: 8px 16px; border-radius: 6px; font-size: var(--font-size-16); font-weight: var(--font-weight-regular); font-family: var(--font-family-base); - height: 40px; line-height: 20px; letter-spacing: -0.16px; - min-width: 120px; - max-width: 120px; text-align: center; cursor: pointer; transition: background-color 0.2s ease; display: flex; align-items: center; justify-content: center; - gap: 6px; } -.outlined-40:hover { - border: 1px solid var(--color-gray-300); +.outlined-40-text:hover { background-color: var(--color-gray-100); } -.outlined-40:active { - border: 1px solid var(--color-gray-300); +.outlined-40-text:active { + background-color: var(--color-gray-100); } -.outlined-40:focus { +.outlined-40-text:focus { border: 1px solid var(--color-gray-500); } -.outlined-40-disabled { +.outlined-40-text-disabled { background-color: var(--color-gray-300); color: var(--color-white); border: 1px solid var(--color-gray-300); cursor: not-allowed; + width: 157px; + height: 40px; padding: 8px 16px; - border-radius: 8px; + border-radius: 6px; font-size: var(--font-size-16); font-weight: var(--font-weight-regular); font-family: var(--font-family-base); line-height: 20px; letter-spacing: -0.16px; - min-width: 120px; - max-width: 120px; text-align: center; display: flex; align-items: center; justify-content: center; - gap: 6px; } -button:disabled img { - filter: brightness(0) invert(1); - opacity: 0.8; -} - -/** 36 OutLined **/ - -.outlined-36 { +/* 3-2. Outlined 36px 아이콘+텍스트 (89×36) - 이모티콘+추가 */ +.outlined-36-text { background-color: var(--color-white); color: var(--color-gray-900); border: 1px solid var(--color-gray-300); + width: 89px; + height: 36px; padding: 6px 16px; border-radius: 6px; font-size: var(--font-size-16); font-weight: var(--font-weight-medium); font-family: var(--font-family-base); - line-height: 18px; - height: var(--button-size-36); + line-height: 24px; letter-spacing: -0.15px; - min-width: 122px; - max-width: 122px; text-align: center; cursor: pointer; transition: background-color 0.2s ease; - display: flex; align-items: center; justify-content: center; + gap: 3px; + + img { + width: 24px; + height: 24px; + } } -.outlined-36:hover { +.outlined-36-text:hover { background-color: var(--color-gray-100); } -.outlined-36:active { +.outlined-36-text:active { background-color: var(--color-gray-100); } -.outlined-36:focus { +.outlined-36-text:focus { border: 1px solid var(--color-gray-500); } -.outlined-36-disabled { +.outlined-36-text-disabled { background-color: var(--color-gray-300); color: var(--color-white); border: 1px solid var(--color-gray-300); cursor: not-allowed; - padding: 6px 14px; + width: 89px; + height: 36px; + padding: 6px 16px; border-radius: 6px; font-size: var(--font-size-16); font-weight: var(--font-weight-medium); font-family: var(--font-family-base); - line-height: 18px; + line-height: 24px; letter-spacing: -0.15px; - min-width: auto; - max-width: 124px; text-align: center; - display: flex; align-items: center; justify-content: center; -} + gap: 3px; -/** 28 OutLined **/ + img { + width: 24px; + height: 24px; + } +} -.outlined-28 { +/* 3-3. Outlined 36px 아이콘만 (56×36) - 헤더 아이콘들 */ +.outlined-36-icon { background-color: var(--color-white); color: var(--color-gray-900); border: 1px solid var(--color-gray-300); - padding: 2px 16px; + width: 56px; + height: 36px; border-radius: 6px; - font-size: var(--font-size-14); - font-weight: var(--font-weight-regular); - font-family: var(--font-family-base); - line-height: 20px; - letter-spacing: -0.15px; - min-width: 122px; - max-width: 122px; - height: 28px; - text-align: center; cursor: pointer; transition: background-color 0.2s ease; - display: flex; align-items: center; justify-content: center; + + img { + width: 24px; + height: 24px; + } } -.outlined-28:hover { +.outlined-36-icon:hover { background-color: var(--color-gray-100); } -.outlined-28:active { - background-color: var (--color-gray-100); +.outlined-36-icon:active { + background-color: var(--color-gray-200); } -.outlined-28:focus { +.outlined-36-icon:focus { border: 1px solid var(--color-gray-500); } -.outlined-28-disabled { +.outlined-36-icon-disabled { background-color: var(--color-gray-300); - color: var(--color-white); border: 1px solid var(--color-gray-300); cursor: not-allowed; - padding: 4px 10px; + width: 56px; + height: 36px; border-radius: 6px; - font-size: var(--font-size-14); - font-weight: var(--font-weight-regular); - font-family: var(--font-family-base); - line-height: 16px; - letter-spacing: -0.15px; - min-width: 122px; - max-width: 122px; - text-align: center; - display: flex; align-items: center; justify-content: center; -} -/** 36 Outlined trash icon **/ + img { + width: 24px; + height: 24px; + filter: brightness(0) invert(1); + opacity: 0.6; + } +} -.outlined-icon-36 { - width: var(--button-size-36); - height: var(--button-size-36); +/* 3-4. Outlined 40px 아이콘만 (40×40) - 휴지통 삭제 */ +.outlined-40-icon { background-color: var(--color-white); + color: var(--color-gray-900); border: 1px solid var(--color-gray-300); + width: 40px; + height: 40px; border-radius: 6px; + cursor: pointer; + transition: background-color 0.2s ease; display: flex; align-items: center; justify-content: center; - cursor: pointer; - transition: background-color 0.2s ease; - &:hover { - background-color: var(--color-gray-100); + img { + width: 24px; + height: 24px; } +} - &:active { - background-color: var(--color-gray-200); - } +.outlined-40-icon:hover { + background-color: var(--color-gray-100); +} - &:focus { - border: 1px solid var(--color-gray-500); - } +.outlined-40-icon:active { + background-color: var(--color-gray-200); +} - &:disabled { - color: var(--color-white); - background-color: var(--color-gray-300); - border-color: var(--color-gray-300); - cursor: not-allowed; +.outlined-40-icon:focus { + border: 1px solid var(--color-gray-500); +} - img { - filter: brightness(0) invert(1); - opacity: 0.6; - } +.outlined-40-icon-disabled { + background-color: var(--color-gray-300); + border: 1px solid var(--color-gray-300); + cursor: not-allowed; + width: 40px; + height: 40px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + + img { + width: 24px; + height: 24px; + filter: brightness(0) invert(1); + opacity: 0.6; } } + +/* 전역 disabled 이미지 스타일 */ +button:disabled img { + filter: brightness(0) invert(1); + opacity: 0.8; +} diff --git a/src/components/Button/IconButton.jsx b/src/components/Button/IconButton.jsx index 61a397c..a3dc9ae 100644 --- a/src/components/Button/IconButton.jsx +++ b/src/components/Button/IconButton.jsx @@ -1,14 +1,24 @@ import React from 'react'; import styles from './IconButton.module.scss'; -function IconButton({ icon, disabled = false, onClick }) { +function IconButton({ + icon, // 이미지 경로만 받음 + enabled = true, + onClick, + className, // + ...rest // 나머지 props 지원 +}) { + const isDisabled = !enabled; + return ( ); } diff --git a/src/components/Button/IconButton.module.scss b/src/components/Button/IconButton.module.scss index 192647a..02333ef 100644 --- a/src/components/Button/IconButton.module.scss +++ b/src/components/Button/IconButton.module.scss @@ -10,6 +10,12 @@ align-items: center; cursor: pointer; transition: background-color 0.2s ease; + + outline: none; + box-shadow: none; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; } .base:hover { @@ -23,6 +29,7 @@ .base:focus { border: 1px solid var(--color-gray-800); background-color: var(--color-gray-700); + outline: none; // } .disabled { @@ -35,4 +42,11 @@ border-radius: 100px; width: var(--button-size-56); height: var(--button-size-56); + + border: none; + outline: none; + box-shadow: none; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; } diff --git a/src/components/Button/IconOnlyButton.jsx b/src/components/Button/IconOnlyButton.jsx deleted file mode 100644 index 277d340..0000000 --- a/src/components/Button/IconOnlyButton.jsx +++ /dev/null @@ -1,16 +0,0 @@ -function IconOnlyButton({ src, alt = '' }) { - return ( - - ); -} - -export default IconOnlyButton; diff --git a/src/components/CardModal.jsx b/src/components/CardModal.jsx index 6127f6e..8a250de 100644 --- a/src/components/CardModal.jsx +++ b/src/components/CardModal.jsx @@ -1,9 +1,11 @@ import styles from './CardModal.module.scss'; import Modal from '@/components/Modal'; import SenderProfile from '@/components/SenderProfile'; +import Editor from '@/components/Editor/Editor'; const CardModal = ({ modalItems, onClose }) => { - const { sender, imageUrl, createdAt, content } = modalItems; + const { sender, imageUrl, createdAt, content, font } = modalItems; + console.log('CardModal', modalItems); return ( @@ -12,7 +14,9 @@ const CardModal = ({ modalItems, onClose }) => { - {content} +
+ +
diff --git a/src/components/ContentViewer/ContentViewer.jsx b/src/components/ContentViewer/ContentViewer.jsx index 4bde6a4..10128ab 100644 --- a/src/components/ContentViewer/ContentViewer.jsx +++ b/src/components/ContentViewer/ContentViewer.jsx @@ -2,6 +2,8 @@ import React, { useMemo } from 'react'; import DOMPurify from 'dompurify'; +import truncate from 'html-truncate'; +import { getFontStyle } from '@/constants/fontMap'; /** * 안전한 HTML 뷰어 @@ -11,24 +13,23 @@ import DOMPurify from 'dompurify'; * @param {number} [props.maxLength] - 최대 텍스트 길이(초과 시 말줄임). 기본값: 무제한 * @param {string} [props.className] - 최외곽 wrapper에 추가할 CSS 클래스 * @param {React.CSSProperties} [props.style] - wrapper에 추가할 인라인 스타일 + * @param {string} [props.font='Pretendard'] - 폰트 이름 (기본: Pretendard) */ -export default function ContentViewer({ content = '', maxLength, className = '', style = {} }) { +export default function ContentViewer({ + content = '', + maxLength, + className = '', + style = {}, + font = 'Pretendard', +}) { // 1) XSS 방지용으로 HTML 소독 const sanitizedHtml = useMemo(() => DOMPurify.sanitize(content), [content]); // 2) 텍스트만 추출해서 maxLength 초과 시 말줄임 처리 const displayedHtml = useMemo(() => { - if (typeof maxLength !== 'number') { - return sanitizedHtml; - } - const div = document.createElement('div'); - div.innerHTML = sanitizedHtml; - const text = div.textContent || ''; - if (text.length <= maxLength) { - return sanitizedHtml; - } - // 포맷 유지 없이 텍스트만 슬라이스하여 말줄임 - return `

${text.slice(0, maxLength)}…

`; + if (typeof maxLength !== 'number') return sanitizedHtml; + // truncate 라이브러리로 HTML을 최대 길이로 자름 + return truncate(sanitizedHtml, maxLength, { ellipsis: '…' }); }, [sanitizedHtml, maxLength]); return ( @@ -39,11 +40,16 @@ export default function ContentViewer({ content = '', maxLength, className = '', overflow: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-word', + ...style, }} > {/* sanitize → truncate된 HTML만 주입 */} -
+
); } diff --git a/src/components/ContentViewer/ContentViewer.module.scss b/src/components/ContentViewer/ContentViewer.module.scss index 56c8d2c..8729f80 100644 --- a/src/components/ContentViewer/ContentViewer.module.scss +++ b/src/components/ContentViewer/ContentViewer.module.scss @@ -6,8 +6,9 @@ .viewer-content { /* 편집기 포맷을 최대한 살리기 위한 기본값 */ - font-family: inherit; // 상위에서 지정한 폰트 상속 + line-height: 1.6; + font-family: inherit; /* 태그별 스타일을 복원 */ b, diff --git a/src/components/Dropdown/Dropdown.module.scss b/src/components/Dropdown/Dropdown.module.scss index e798e85..9a67f90 100644 --- a/src/components/Dropdown/Dropdown.module.scss +++ b/src/components/Dropdown/Dropdown.module.scss @@ -14,13 +14,13 @@ background-color: var(--color-white); &:hover { - border: 1px solid var(--color-gray-500); + border: 1px solid var(--color-purple-600); color: var(--color-gray-500); } &:focus, &:active { - border: 2px solid var(--color-gray-500); + border: 2px solid var(--color-purple-600); color: var(--color-gray-900); } diff --git a/src/components/Editor/Editor.jsx b/src/components/Editor/Editor.jsx index 5af093c..5c67ca1 100644 --- a/src/components/Editor/Editor.jsx +++ b/src/components/Editor/Editor.jsx @@ -8,8 +8,10 @@ import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'; import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { $generateHtmlFromNodes, $generateNodesFromDOM } from '@lexical/html'; +import { $getRoot } from 'lexical'; import ToolbarPlugin from './Toolbar'; import styles from './Editor.module.scss'; +import { getFontFamily } from '@/constants/fontMap'; /** * 에디터 내용 변경 시 HTML을 상위(onUpdate)로 전달 @@ -35,8 +37,18 @@ function OnEditorChange({ onUpdate }) { * @param {string} content - 초기 HTML * @param {(html: string) => void} onUpdate - 변경된 HTML 콜백 * @param {React.CSSProperties} [style] - 에디터 스타일 + * @param {string} [font='Pretendard'] - 에디터 폰트 (기본: Pretendard) + * @param {string} [className=''] - 최외곽 wrapper에 추가할 CSS 클래스 + * @param {boolean} [readOnly=false] - 읽기 전용 모드 여부 */ -export default function Editor({ content = '', onUpdate, style = {} }) { +export default function Editor({ + content = '', + onUpdate = () => {}, + style = {}, + readOnly = false, + font = 'Pretendard', + className = '', +}) { // 1) theme: 텍스트 포맷 → CSS 클래스 매핑 const theme = { text: { @@ -46,38 +58,63 @@ export default function Editor({ content = '', onUpdate, style = {} }) { strikethrough: styles.editor__textStrikethrough, }, }; + console.log('foooont', font, 'getFontFamily(font)', getFontFamily(font)); - // 2) initialEditorState: content가 있으면 파싱해서 노드 트리로 초기화 + // 2) initialEditorState: content가 있으면 파싱해서 DOM => 노드 트리로 초기화 const initialEditorState = useMemo(() => { if (!content) return null; const dom = new DOMParser().parseFromString(content, 'text/html'); - return (editor) => $generateNodesFromDOM(editor, dom); + + return (editor) => { + editor.update(() => { + const nodes = $generateNodesFromDOM(editor, dom); // DOM에서 노드 생성 + const root = $getRoot(); // 현재 에디터의 루트 노드 가져오기 + root.clear(); // 기존 내용 삭제 (필요 없으면 제거) + root.append(...nodes); // 파싱된 노드 삽입 + }); + }; }, [content]); // 3) initialConfig: LexicalComposer에 전달할 초기 설정 const initialConfig = { - namespace: 'MessageEditor', - theme, - initialEditorState, + namespace: 'MessageEditor', // 네임스페이스 설정 + theme, // 에디터 테마 설정 + editorState: initialEditorState, // 초기 에디터 상태 설정 + editable: !readOnly, // 읽기 전용 모드 설정 onError(error) { console.error('Lexical Error:', error); }, }; return ( -
+
- + {!readOnly && } {/* RichTextPlugin 하나로 bold/italic/underline/strikethrough 지원 */} } - placeholder={
내용을 입력해 주세요…
} + contentEditable={ + + } + placeholder={ + readOnly ? null : ( +
내용을 입력해 주세요…
+ ) + } ErrorBoundary={LexicalErrorBoundary} /> - - + {!readOnly && } + {!readOnly && }
); diff --git a/src/components/Editor/Editor.module.scss b/src/components/Editor/Editor.module.scss index 62f8d9d..74914c6 100644 --- a/src/components/Editor/Editor.module.scss +++ b/src/components/Editor/Editor.module.scss @@ -5,7 +5,7 @@ min-height: 260px; padding: 12px; outline: none; - font-family: inherit; + font-family: inherit !important; /* 부모에서 상속받은 폰트 사용 */ font-size: var(--font-size-16); color: var(--color-gray-900); diff --git a/src/components/Editor/Toolbar.jsx b/src/components/Editor/Toolbar.jsx index 0930e0e..f814c3b 100644 --- a/src/components/Editor/Toolbar.jsx +++ b/src/components/Editor/Toolbar.jsx @@ -20,8 +20,10 @@ import { } from 'lexical'; // SVG 아이콘을 import (Vite, CRA 환경에서 URL 형태로 반환됨) -import UndoIcon from '@/assets/editorIcons/arrow-counterclockwise.svg'; -import RedoIcon from '@/assets/editorIcons/arrow-clockwise.svg'; +import UndoIcon from '@/assets/editorIcons/icon_undo_active.svg'; +import UndoInactiveIcon from '@/assets/editorIcons/icon_undo_inactive.svg'; +import RedoIcon from '@/assets/editorIcons/icon_redo_active.svg'; +import RedoInactiveIcon from '@/assets/editorIcons/icon_redo_inactive.svg'; import BoldIcon from '@/assets/editorIcons/type-bold.svg'; import ItalicIcon from '@/assets/editorIcons/type-italic.svg'; import UnderlineIcon from '@/assets/editorIcons/type-underline.svg'; @@ -118,21 +120,25 @@ export default function ToolbarPlugin() { {/** Undo 버튼 **/} {/** Redo 버튼 **/} diff --git a/src/components/Editor/components/ToolbarButton.jsx b/src/components/Editor/components/ToolbarButton.jsx index c84e55a..d954c4a 100644 --- a/src/components/Editor/components/ToolbarButton.jsx +++ b/src/components/Editor/components/ToolbarButton.jsx @@ -7,21 +7,25 @@ import styles from './ToolbarButton.module.scss'; * ToolbarButton 컴포넌트 * * @param {string} icon 버튼에 표시할 SVG URL + * @param {string} inactiveIcon 비활성화 상태에서 표시할 SVG URL (선택적) * @param {string} label aria-label * @param {boolean} disabled 버튼 비활성화 여부 * @param {object} editor Lexical 에디터 인스턴스 (useLexicalComposerContext()[0]) * @param {object} command 실행할 Lexical 커맨드 상수 (e.g. FORMAT_TEXT_COMMAND) * @param {any} commandArg 커맨드에 넘길 payload (e.g. 'bold', 'italic', undefined) * @param {boolean} active active 상태이면 강조 스타일 적용 + * @param {object} style 추가할 인라인 스타일 */ export default React.memo(function ToolbarButton({ icon, + inactiveIcon = null, label, disabled = false, editor = null, command = null, commandArg = undefined, active = false, + style = {}, }) { const onClick = useCallback(() => { if (editor && command) { @@ -30,15 +34,17 @@ export default React.memo(function ToolbarButton({ console.log(`Command dispatched: ${command}`, commandArg); }, [editor, command, commandArg]); + const displayIcon = disabled && inactiveIcon ? inactiveIcon : icon; + return ( ); }); diff --git a/src/components/Editor/components/ToolbarButton.module.scss b/src/components/Editor/components/ToolbarButton.module.scss index 39d3283..177db01 100644 --- a/src/components/Editor/components/ToolbarButton.module.scss +++ b/src/components/Editor/components/ToolbarButton.module.scss @@ -29,10 +29,6 @@ &:disabled { cursor: not-allowed; - & img { - //opacity: 0.5;는 드롭다운이랑 겹침 - display: none; - } } @include mobile { diff --git a/src/components/HorizontalScrollContainer/HorizontalScrollContainer.jsx b/src/components/HorizontalScrollContainer/HorizontalScrollContainer.jsx index f186945..f9b0d05 100644 --- a/src/components/HorizontalScrollContainer/HorizontalScrollContainer.jsx +++ b/src/components/HorizontalScrollContainer/HorizontalScrollContainer.jsx @@ -11,11 +11,17 @@ import styles from './HorizontalScrollContainer.module.scss'; * * @param {{ * children: React.ReactNode, + * hideScroll: boolean // 스크롤 표시 여부 * className?: string, // 추가적인 클래스 네임 (선택) * style?: React.CSSProperties, // inline style (선택) * }} props */ -export default function HorizontalScrollContainer({ children, className = '', style = {} }) { +export default function HorizontalScrollContainer({ + children, + hideScroll = true, + className = '', + style = {}, +}) { const containerRef = useRef(null); useEffect(() => { @@ -38,7 +44,11 @@ export default function HorizontalScrollContainer({ children, className = '', st }, []); return ( -
+
{children}
); diff --git a/src/components/HorizontalScrollContainer/HorizontalScrollContainer.module.scss b/src/components/HorizontalScrollContainer/HorizontalScrollContainer.module.scss index e386710..4b106f4 100644 --- a/src/components/HorizontalScrollContainer/HorizontalScrollContainer.module.scss +++ b/src/components/HorizontalScrollContainer/HorizontalScrollContainer.module.scss @@ -7,7 +7,9 @@ align-items: center; overflow-x: auto; overflow-y: hidden; +} +.horizontal-scroll--hide-scrollbar { /* Firefox 스크롤바 숨김 */ @supports (scrollbar-width: none) { scrollbar-width: none; /* Firefox 에서만 스크롤바 숨김 */ diff --git a/src/components/InfinityScrollWrapper/InfinityScrollWrapper.jsx b/src/components/InfinityScrollWrapper/InfinityScrollWrapper.jsx index 2d126a0..7b6c57a 100644 --- a/src/components/InfinityScrollWrapper/InfinityScrollWrapper.jsx +++ b/src/components/InfinityScrollWrapper/InfinityScrollWrapper.jsx @@ -1,7 +1,20 @@ import styles from './InfinityScrollWrapper.module.scss'; import { useEffect, useRef } from 'react'; -const InfinityScrollWrapper = ({ children, hasNext, callback }) => { +/* + children: 자식 요소 + hasNext: 다음 데이터가 있는 지 여부 (true, false) + callback: 스크롤 끝에 도달했을 때 수행할 메소드 + isHorizontal: 무한 스크롤 가로 여부 + scrollObserverRef: 스크롤 대상 +*/ +const InfinityScrollWrapper = ({ + children, + hasNext, + callback, + isHorizontal = false, + scrollObserverRef, +}) => { const observerRef = useRef(null); useEffect(() => { @@ -14,7 +27,10 @@ const InfinityScrollWrapper = ({ children, hasNext, callback }) => { }; /* 무한 스크롤 감시 */ - const observer = new IntersectionObserver(onScroll, { threshold: 0.5 }); + const observer = new IntersectionObserver(onScroll, { + threshold: 0.5, + root: scrollObserverRef?.current ?? null, + }); const currentRef = observerRef.current; if (currentRef) { observer.observe(currentRef); @@ -26,10 +42,10 @@ const InfinityScrollWrapper = ({ children, hasNext, callback }) => { } observer.disconnect(); }; - }, [observerRef, hasNext, callback]); + }, [observerRef, hasNext, callback, isHorizontal, scrollObserverRef]); return ( -
+
{children}
diff --git a/src/components/InfinityScrollWrapper/InfinityScrollWrapper.module.scss b/src/components/InfinityScrollWrapper/InfinityScrollWrapper.module.scss index 978227f..840a9a4 100644 --- a/src/components/InfinityScrollWrapper/InfinityScrollWrapper.module.scss +++ b/src/components/InfinityScrollWrapper/InfinityScrollWrapper.module.scss @@ -1,5 +1,12 @@ .container { - width: 100%; + &--vertical { + width: 100%; + } + + &--horizontal { + display: flex; + flex-direction: row; + } } .container__observer { diff --git a/src/components/SenderProfile.module.scss b/src/components/SenderProfile.module.scss index da5f8df..76bba5f 100644 --- a/src/components/SenderProfile.module.scss +++ b/src/components/SenderProfile.module.scss @@ -24,6 +24,13 @@ font-weight: 400; font-size: 20px; line-height: 24px; + text-overflow: ellipsis; + overflow: hidden; + word-break: break-word; + display: -webkit-box; + -webkit-line-clamp: 1; + line-clamp: 1; + -webkit-box-orient: vertical; } .name { diff --git a/src/constants/fontMap.js b/src/constants/fontMap.js index 706b498..e46c638 100644 --- a/src/constants/fontMap.js +++ b/src/constants/fontMap.js @@ -8,19 +8,22 @@ /** * FONT_STYLES: 각 폰트 이름 → 스타일 매핑 * (COLOR_STYLES와 동일한 형태) + * */ -export const FONT_STYLES = { - 'Noto Sans': { - fontFamily: 'var(--font-family-noto-sans)', - }, + +const FONT_STYLES = { Pretendard: { - fontFamily: 'var(--font-family-base)', + /* --font-family-base 가 없다면 즉시 'Pretendard', sans-serif 사용 */ + fontFamily: "var(--font-family-base, 'Pretendard', sans-serif)", + }, + 'Noto Sans': { + fontFamily: "var(--font-family-noto-sans, 'Noto Sans', sans-serif)", }, 나눔명조: { - fontFamily: 'var(--font-family-nanum-myeongjo)', + fontFamily: "var(--font-family-nanum-myeongjo, 'Nanum Myeongjo', serif)", }, '나눔손글씨 손편지체': { - fontFamily: 'var(--font-family-nanum-son-pyeonji)', + fontFamily: "var(--font-family-nanum-son-pyeonji, 'Nanum Son Pyeonji', cursive)", }, }; @@ -45,3 +48,27 @@ export const FONT_DROPDOWN_ITEMS = FONT_OPTIONS.map((fontName) => ({ fontFamily: FONT_STYLES[fontName].fontFamily, }, })); + +/** + * getFontStyle: 폰트 이름에 해당하는 스타일 객체를 반환 + * - 폰트 이름이 FONT_STYLES에 없으면 기본 폰트(Noto Sans) 스타일 반환 + * @param {*} fontName + */ + +export function getFontStyle(fontName) { + if (FONT_STYLES[fontName]) { + return FONT_STYLES[fontName]; + } + // 없다면 에러 던짐 + throw new Error(`존재하지 않는 폰트 : ${fontName}`); +} + +/** + * getFontFamily: 폰트 이름에 해당하는 fontFamily 값을 반환 + * - 폰트 이름이 FONT_STYLES에 없으면 기본 폰트(Noto Sans) 스타일 반환 + * @param {string} fontName + * @returns {string} + */ +export function getFontFamily(fontName) { + return getFontStyle(fontName).fontFamily; +} diff --git a/src/hooks/useKakaoShare.jsx b/src/hooks/useKakaoShare.jsx index f490f7d..e3eae2f 100644 --- a/src/hooks/useKakaoShare.jsx +++ b/src/hooks/useKakaoShare.jsx @@ -5,7 +5,7 @@ export const useKakaoShare = () => { if (!window.Kakao || !window.Kakao.isInitialized()) return; const currentUrl = window.location.href; const origin = window.location.origin; - const imageUrl = `${origin}/images/image_opengraph.png`; + const imageUrl = `${origin}/images/image_opengraph_narrow.png`; window.Kakao.Share.sendDefault({ objectType: 'feed', content: { diff --git a/src/hooks/useMessageItemsList.jsx b/src/hooks/useMessageItemsList.jsx index 2807ea3..c2331f1 100644 --- a/src/hooks/useMessageItemsList.jsx +++ b/src/hooks/useMessageItemsList.jsx @@ -3,18 +3,51 @@ import { useApi } from '@/hooks/useApi.jsx'; import { listRecipientMessages } from '@/apis/recipientMessageApi'; import { deleteMessage } from '@/apis/messagesApi'; import { deleteRecipient } from '@/apis/recipientsApi'; +import { getRecipient } from '@/apis/recipientsApi'; export const useMessageItemsList = (id) => { /* useApi 사용하여 메시지 리스트 호출 */ const { data: messageList, - loading, + loading: messageLoading, refetch: getMessageListRefetch, } = useApi(listRecipientMessages, { recipientId: id, limit: 8, offset: 0 }, { immediate: true }); /* useApi 삭제 관련 Api */ - const { refetch: deleteMessageRefetch } = useApi(deleteMessage, { id }, { immediate: false }); - const { refetch: deleteRecipientRefetch } = useApi(deleteRecipient, { id }, { immediate: false }); + const { loading: deleteMessageLoading, refetch: deleteMessageRefetch } = useApi( + deleteMessage, + { id }, + { immediate: false }, + ); + const { loading: deleteRecipientLoading, refetch: deleteRecipientRefetch } = useApi( + deleteRecipient, + { id }, + { immediate: false }, + ); + + const { loading: recipientDataLoading, data: recipientData } = useApi( + getRecipient, + { id }, + { immediate: true }, + ); + + const [showOverlay, setShowOverlay] = useState(false); + const isLoading = + recipientDataLoading || messageLoading || deleteMessageLoading || deleteRecipientLoading; + + const getLoadingDescription = () => { + let description = ''; + if (recipientDataLoading || messageLoading) { + description = '롤링페이퍼 메시지 목록을 불러오고 있어요'; + } else if (deleteMessageLoading) { + description = '롤링페이퍼 메시지를 삭제하고 있어요'; + } else if (deleteRecipientLoading) { + description = '롤링페이퍼를 삭제하고 있어요'; + } + return description; + }; + + const loadingDescription = getLoadingDescription(); const [itemList, setItemList] = useState([]); const hasNext = !!messageList?.next; @@ -24,14 +57,21 @@ export const useMessageItemsList = (id) => { /* API 실행 후 데이터 세팅 */ useEffect(() => { - if (loading || !messageList) return; + if (messageLoading || !messageList) return; const { results, previous } = messageList; setItemList((prevList) => (isFirstCall || !previous ? results : [...prevList, ...results])); - }, [messageList, isFirstCall, loading]); + }, [messageList, isFirstCall, messageLoading]); + + /* 롤링페이퍼 삭제 시 로딩 오버레이 컴포넌트 처리 */ + useEffect(() => { + if (deleteRecipientLoading) { + setShowOverlay(true); + } + }, [deleteRecipientLoading]); /* 스크롤 시 데이터 다시 불러옴 */ const loadMore = () => { - if (loading || !hasNext) return; + if (messageLoading || !hasNext) return; const newOffset = isFirstCall ? offset + 8 : offset + 6; getMessageListRefetch({ recipientId: id, limit: 6, offset: newOffset }); setOffset(newOffset); @@ -39,7 +79,7 @@ export const useMessageItemsList = (id) => { /* 삭제 후 데이터 초기 상태로 불러옴 */ const initializeList = () => { - if (loading) return; + if (messageLoading) return; setOffset(0); getMessageListRefetch({ recipientId: id, limit: 8, offset: 0 }); }; @@ -63,5 +103,15 @@ export const useMessageItemsList = (id) => { } }; - return { itemList, hasNext, loading, loadMore, onClickDeleteMessage, onDeletePaperConfirm }; + return { + recipientData, + itemList, + hasNext, + isLoading, + showOverlay, + loadingDescription, + loadMore, + onClickDeleteMessage, + onDeletePaperConfirm, + }; }; diff --git a/src/hooks/useSliderPaging.jsx b/src/hooks/useSliderPaging.jsx new file mode 100644 index 0000000..e01315a --- /dev/null +++ b/src/hooks/useSliderPaging.jsx @@ -0,0 +1,60 @@ +// src/hooks/useSliderPaging.js +import { useState, useRef, useEffect, useCallback } from 'react'; + +export function useSliderPaging({ totalItems, pageSize, cardWidth, gap, breakpoint = 1200 }) { + const wrapperRef = useRef(null); + + // 1) 뷰포트가 데스크톱 모드인지 + const [isDesktop, setIsDesktop] = useState(window.innerWidth >= breakpoint); + useEffect(() => { + const onResize = () => setIsDesktop(window.innerWidth >= breakpoint); + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, [breakpoint]); + + // 2) 페이지 인덱스 + const [pageIndex, setPageIndex] = useState(0); + const totalPage = Math.max(0, Math.ceil(totalItems / pageSize) - 1); + + // 3) 한 페이지당 스크롤 픽셀 + const offset = pageSize * (cardWidth + gap); + + // 4) 데스크톱: 버튼 클릭 시 페이지 이동 + const slideTo = useCallback( + (idx) => { + const el = wrapperRef.current; + if (!el) return; + el.scrollTo({ left: idx * offset, behavior: 'smooth' }); + setPageIndex(idx); + }, + [offset], + ); + + const canPrev = pageIndex > 0; + const canNext = pageIndex < totalPage; + const goPrev = () => canPrev && slideTo(pageIndex - 1); + const goNext = () => canNext && slideTo(pageIndex + 1); + + // 5) 모바일·태블릿: 직접 스크롤 → 페이지 인덱스 동기화 + useEffect(() => { + if (isDesktop) return; + const el = wrapperRef.current; + if (!el) return; + const onScroll = () => { + const idx = Math.round(el.scrollLeft / offset); + setPageIndex(Math.min(Math.max(idx, 0), totalPage)); + }; + el.addEventListener('scroll', onScroll, { passive: true }); + return () => el.removeEventListener('scroll', onScroll); + }, [isDesktop, offset, totalPage]); + + return { + wrapperRef, + isDesktop, + pageIndex, + canPrev, + canNext, + goPrev, + goNext, + }; +} diff --git a/src/pages/HomePage/HomePage.module.scss b/src/pages/HomePage/HomePage.module.scss index b858458..ade1fed 100644 --- a/src/pages/HomePage/HomePage.module.scss +++ b/src/pages/HomePage/HomePage.module.scss @@ -1,4 +1,4 @@ -* { +body { box-sizing: border-box; font-family: var(--font-family-base); } diff --git a/src/pages/ListPage/ListPage.jsx b/src/pages/ListPage/ListPage.jsx index 7e694c8..0f13860 100644 --- a/src/pages/ListPage/ListPage.jsx +++ b/src/pages/ListPage/ListPage.jsx @@ -8,11 +8,14 @@ import { listRecipients } from '../../apis/recipientsApi'; import { useApi } from '../../hooks/useApi'; const ListPage = () => { + /* 무한스크롤: Api 요청 데이터에서 next값이 있는지 확인, true 일때만 데이터를 불러옴 */ + const hasNext = false; + /* 무한스크롤: 추가 데이터 로드 */ + const loadMore = () => {}; + // 1) useApi로 전체 Recipient 목록(fetch) 요청 const { data: listData, - loading: listLoading, - error: listError, // refetch 필요 시 사용 가능 } = useApi( listRecipients, @@ -44,30 +47,22 @@ const ListPage = () => { setRecentCards(byRecent); }, [listData]); - // 4) 로딩/에러 처리 - if (listLoading) { - return
로딩 중...
; - } - if (listError) { - return
에러 발생: {listError}
; - } - return (
{/* 인기 롤링 페이퍼 🔥 */}

인기 롤링 페이퍼 🔥

- +
{/* 최근에 만든 롤링 페이퍼 ⭐️ */}

최근에 만든 롤링 페이퍼 ⭐️

- +
- +
); diff --git a/src/pages/ListPage/ListPage.module.scss b/src/pages/ListPage/ListPage.module.scss index f7010af..9dcd731 100644 --- a/src/pages/ListPage/ListPage.module.scss +++ b/src/pages/ListPage/ListPage.module.scss @@ -2,21 +2,34 @@ padding: 24px; display: flex; flex-direction: column; - justify-content: center; width: var(--wrapper-width); + min-height: 100dvh; gap: 48px; margin: 0 auto; + padding-top: 50px; + overflow: visible; + @media screen and (min-width: 768px) and (max-width: 1199px) { + padding: 0 24px; + width: 100%; + min-height: 100dvh; + } + + @media screen and (max-width: 767px) { + padding: 0 20px; + width: 100%; + padding-top: 40px; + min-height: 100dvh; + } &__section { display: flex; flex-direction: column; - justify-content: flex-start; gap: 16px; + overflow: visible; } &__title { margin: 0; - font-size: var(--font-size-24); font-weight: var(--font-weight-bold); color: #333; @@ -28,4 +41,21 @@ text-align: center; color: var(--color-gray-500); } + + &__createButton { + width: 280px; + height: 56px; + padding: 14px 24px; + background-color: var(--color-purple-600); + border: none; + border-radius: 12px; + font-weight: 700; + font-size: 18px; + line-height: 28px; + color: var(--color-white); + + @media screen and (min-width: 355px) and (max-width: 1199px) { + width: 100%; + } + } } diff --git a/src/pages/ListPage/components/ItemCard.jsx b/src/pages/ListPage/components/ItemCard.jsx index 078353f..ffc2bb0 100644 --- a/src/pages/ListPage/components/ItemCard.jsx +++ b/src/pages/ListPage/components/ItemCard.jsx @@ -1,9 +1,13 @@ // src/components/ItemCard/ItemCard.jsx +import React from 'react'; import { Link } from 'react-router-dom'; import styles from './ItemCard.module.scss'; +import { COLOR_STYLES } from '@/constants/colorThemeStyle'; +import ShowAvatars from './ShowAvatars'; +import ShowEmoji from './ShowEmoji'; -const ItemCard = ({ data }) => { - const { +const ItemCard = ({ + data: { id, name, backgroundColor, @@ -11,73 +15,60 @@ const ItemCard = ({ data }) => { messageCount, recentMessages = [], topReactions = [], - } = data; - - // 배경 설정: 이미지가 있으면 이미지, 없으면 컬러 + }, +}) => { + const { primary, border, accent } = COLOR_STYLES[backgroundColor] || {}; const backgroundStyle = backgroundImageURL ? { backgroundImage: `url(${backgroundImageURL})`, backgroundSize: 'cover', backgroundPosition: 'center', } - : { - backgroundColor: `var(--color-${backgroundColor}-200)`, - }; + : { backgroundColor: primary, borderColor: border }; - // 이미지 배경인 경우 텍스트를 흰색으로 바꾸는 클래스 const contentClass = backgroundImageURL ? `${styles['item-card__content']} ${styles['item-card__content--image']}` : styles['item-card__content']; - // 최근 메시지 중 상위 3개만 가져오기 - const topThree = recentMessages.slice(0, 3); - // 남은 작성자 수 계산 - const extraCount = Math.max(0, messageCount - topThree.length); - return (
+ {backgroundImageURL && ( +
+ )} +

To. {name}

{messageCount}명이 작성했어요!

- {/* 최대 3개 프로필 표시 */} - {topThree.length > 0 && ( -
- {topThree.map((msg, idx) => ( - {msg.sender} - ))} - - {/* 나머지 작성자 수 +n 표시 */} - {extraCount > 0 && ( -
- +{extraCount} -
- )} -
+ {/* 프로필 아바타 영역 (최대 3) */} + {recentMessages.length > 0 ? ( + + ) : ( +
)} - {/* 구분선 */} -
+
- {/* 상위 이모지 반응 */} - {topReactions.length > 0 && ( -
- {topReactions.map((r, idx) => ( - - {r.emoji} {r.cocunt} - - ))} -
+ {/* 반응 이모지 영역 (최대 3) */} + {topReactions.length > 0 ? ( + + ) : ( +
)}
diff --git a/src/pages/ListPage/components/ItemCard.module.scss b/src/pages/ListPage/components/ItemCard.module.scss index 060097c..2f70c89 100644 --- a/src/pages/ListPage/components/ItemCard.module.scss +++ b/src/pages/ListPage/components/ItemCard.module.scss @@ -1,8 +1,6 @@ -/* Link가 기본 밑줄이나 색상 없이 동작하도록 */ .item-card__link { display: block; text-decoration: none; - color: inherit; } .item-card { @@ -13,6 +11,13 @@ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); border-radius: 16px; border: 1px solid rgba(0, 0, 0, 0.1); + transform: scale(1); + transition: transform 0.5s; + will-change: transform; + transform: translateZ(0); + &:hover { + transform: scale(1.05); + } &__content { display: flex; @@ -41,46 +46,11 @@ font-size: var(--font-size-16); } - &__avatars { - display: flex; - align-items: center; - position: relative; - height: 32px; - margin-top: 8px; - } - - &__avatar { - width: 32px; - height: 32px; - border: 2px solid var(--color-white); - border-radius: 50%; - background-color: var(--color-white); - overflow: hidden; - position: relative; - margin-left: -12px; - display: flex; - align-items: center; - justify-content: center; - font-size: var(--font-size-14); - color: var(--color-gray-500); - - img { - width: 100%; - height: 100%; - object-fit: cover; - } - } - - /* +n 텍스트 */ - &__more { - font-size: var(--font-size-14); - font-weight: var(--font-weight-medium); - } - &__top-reactions { display: flex; gap: 8px; margin-top: 8px; + min-height: 24px; } &__reaction { diff --git a/src/pages/ListPage/components/ShowAvatars.jsx b/src/pages/ListPage/components/ShowAvatars.jsx new file mode 100644 index 0000000..e0a1b87 --- /dev/null +++ b/src/pages/ListPage/components/ShowAvatars.jsx @@ -0,0 +1,73 @@ +// src/components/ProfileGroup/ShowAvatars.jsx +import React from 'react'; +import styles from './ShowAvatars.module.scss'; + +/** + * showavatars 컴포넌트 + * + * @param {object} props + * @param {Array} props.profiles - 정렬된 프로필 메시지 배열 + * @param {number} props.totalCount - data.count (전체 메시지 수) + * @param {boolean} props.loading + * @param {Error|null} props.error + */ +export default function ShowAvatars({ profiles, totalCount, loading, error }) { + // 로딩 상태 표시: 세 개의 스피너 원 + if (loading) { + return ( +
+
+
+
+
+ ); + } + + // 에러 상태 + if (error) { + return
오류 발생
; + } + + // 작성자 수가 0명일 때 빈 컨테이너 + if (totalCount === 0) { + return
; + } + + // 최대 3명까지 실제 프로필 + const visibleCount = Math.min(totalCount, 3); + const visibleProfiles = profiles.slice(0, visibleCount); + let extraCount = totalCount > 3 ? totalCount - 3 : 0; + if (extraCount > 99) extraCount = 99; + const displayExtra = extraCount === 99 ? '99+' : extraCount; + + // 슬롯 오프셋 + const GAP = 16; + + return ( +
+ {/* 실제 프로필 아바타 */} + {visibleProfiles.map((profile, idx) => ( + {profile.sender} + ))} + + {/* +n 표시 */} + {extraCount > 0 && ( +
+ +{displayExtra} +
+ )} +
+ ); +} diff --git a/src/pages/ListPage/components/ShowAvatars.module.scss b/src/pages/ListPage/components/ShowAvatars.module.scss new file mode 100644 index 0000000..067def9 --- /dev/null +++ b/src/pages/ListPage/components/ShowAvatars.module.scss @@ -0,0 +1,87 @@ +.show-avatars { + display: flex; + align-items: center; + position: relative; +} + +.show-avatars--spinner { + display: flex; + align-items: center; + opacity: 0; + animation: fade-in-spinner 2s forwards; +} + +.show-avatars--error { + font-size: 14px; + color: #e53e3e; +} + +.show-avatars--empty { + display: flex; + align-items: center; +} + +.show-avatars__avatar { + width: 28px; + height: 28px; + border-radius: 50%; + object-fit: cover; + border: 2px solid var(--color-white); + background-color: var(--color-blue-200); +} + +.show-avatars__extra { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + margin-left: -20px; /* 아바타와 동일하게 겹침 */ + border-radius: 50%; + background-color: var(--color-white); + font-size: var(--font-size-12); + color: var(--color-gray-600); + border: 2px solid var(--color-gray-100); + z-index: 999; +} + +.show-avatars__count { + margin-left: 8px; + font-size: var(--font-size-18); + color: var(--color-gray-900); +} +.show-avatars__count-number { + font-weight: bold; + color: var(--color-black); +} + +.show-avatars__spinner-circle { + width: 16px; + height: 16px; + margin-right: 6px; + border-radius: 50%; + background-color: var(--color-blue-500); + animation: show-avatars-pulse 2s infinite ease-in-out; +} + +/* pulse 애니메이션 정의 */ +@keyframes show-avatars-pulse { + 0% { + opacity: 1; + } + 50% { + opacity: 0.3; + } + 100% { + opacity: 1; + } +} + +@keyframes fade-in-spinner { + from { + opacity: 0; + } + to { + opacity: 1; + } +} diff --git a/src/pages/ListPage/components/ShowEmoji.jsx b/src/pages/ListPage/components/ShowEmoji.jsx new file mode 100644 index 0000000..6ba2992 --- /dev/null +++ b/src/pages/ListPage/components/ShowEmoji.jsx @@ -0,0 +1,31 @@ +// src/components/EmojiGroup/ToggleEmoji.jsx +import React from 'react'; +import EmojiBadge from '@/components/PostHeader/EmojiGroup/EmojiBadge.jsx'; +import Style from './ShowEmoji.module.scss'; + +/** + * ToggleEmoji 컴포넌트 재편집 + * + * @param {object} props + * @param {Array<{ id: number, emoji: string, count: number }>} props.emojis + * - 백엔드에서 count 내림차순으로 이미 정렬된 최대 8개의 이모지 리스트 + */ +export default function ShowEmoji({ emojis }) { + // 1) 상위 3개만 보여주기(겹치지 않음) + const visibleCount = Math.min(emojis.length, 3); + const visibleEmojis = emojis.slice(0, visibleCount); + + return ( +
+ {visibleEmojis.map((item) => ( + + ))} +
+ ); +} diff --git a/src/pages/ListPage/components/ShowEmoji.module.scss b/src/pages/ListPage/components/ShowEmoji.module.scss new file mode 100644 index 0000000..95ce123 --- /dev/null +++ b/src/pages/ListPage/components/ShowEmoji.module.scss @@ -0,0 +1,7 @@ +/* src/components/EmojiGroup/ToggleEmoji.module.scss */ + +.show-emoji { + display: flex; + align-items: center; + gap: 8px; /* 각 뱃지 간격 */ +} diff --git a/src/pages/ListPage/components/Slider.jsx b/src/pages/ListPage/components/Slider.jsx index abe6c82..e4113da 100644 --- a/src/pages/ListPage/components/Slider.jsx +++ b/src/pages/ListPage/components/Slider.jsx @@ -1,43 +1,56 @@ -import { useState } from 'react'; +import { useRef } from 'react'; import styles from './Slider.module.scss'; import ItemCard from './ItemCard'; - -const Slider = ({ cards }) => { - const [currentIndex, setCurrentIndex] = useState(0); - const cardsPerPage = 4; - const maxIndex = Math.max(0, Math.ceil(cards.length / cardsPerPage) - 1); - - const handlePrev = () => setCurrentIndex((i) => Math.max(i - 1, 0)); - const handleNext = () => setCurrentIndex((i) => Math.min(i + 1, maxIndex)); - - const cardsToShow = cards.slice( - currentIndex * cardsPerPage, - currentIndex * cardsPerPage + cardsPerPage, - ); - - const showPrev = cards.length > cardsPerPage && currentIndex > 0; - const showNext = cards.length > cardsPerPage && currentIndex < maxIndex; +import ArrowButton from '../../../components/Button/ArrowButton'; +import HorizontalScrollContainer from '../../../components/HorizontalScrollContainer/HorizontalScrollContainer'; +import { useSliderPaging } from '@/hooks/useSliderPaging'; +import InfinityScrollWrapper from '@/components/InfinityScrollWrapper/InfinityScrollWrapper'; + +const CARD_WIDTH = 275; +const GAP = 16; +const PAGE_SIZE = 4; + +const Slider = ({ cards, hasNext, loadMore }) => { + /* 무한 스크롤: 스크롤 감지 ref 요소 전달 */ + const scrollObserverRef = useRef(null); + + const { wrapperRef, isDesktop, pageIndex, canPrev, canNext, goPrev, goNext } = useSliderPaging({ + totalItems: cards.length, + pageSize: PAGE_SIZE, + cardWidth: CARD_WIDTH, + gap: GAP, + breakpoint: 1200, + }); + + // 데스크톱 전용: 현재 페이지에 해당하는 카드만 + const visibleCards = isDesktop + ? cards.slice(pageIndex * PAGE_SIZE, pageIndex * PAGE_SIZE + PAGE_SIZE) + : cards; return (
- {showPrev && ( - + {isDesktop && canPrev && ( +
+ +
)} -
-
- {cardsToShow.map((card, idx) => ( - - ))} -
+
+ + +
+ {visibleCards.map((c) => ( + + ))} +
+
+
- {showNext && ( - + {isDesktop && canNext && ( +
+ +
)}
); diff --git a/src/pages/ListPage/components/Slider.module.scss b/src/pages/ListPage/components/Slider.module.scss index ef9fea7..03c914e 100644 --- a/src/pages/ListPage/components/Slider.module.scss +++ b/src/pages/ListPage/components/Slider.module.scss @@ -2,42 +2,49 @@ position: relative; display: flex; align-items: center; - margin: 0; - - &__arrow--left, - &__arrow--right { - background-color: var(--color-white); - border: none; - border-radius: 50%; - width: 40px; - height: 40px; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); - cursor: pointer; - font-size: var(--font-size-18); - display: flex; - align-items: center; - justify-content: center; - z-index: 1; - } + overflow: visible; - &__arrow--left { - position: absolute; - left: -20px; - } + &__arrow { + &--left, + &--right { + // 기본: 숨김 + display: none; + + // 공통 위치 및 스타일 + position: absolute; + top: 50%; + transform: translateY(-50%); + z-index: 2; - &__arrow--right { - position: absolute; - right: -20px; + // 데스크톱에서만 보이도록 + @media (min-width: 1200px) { + display: flex; + } + } + + &--left { + left: -20px; + } + + &--right { + right: -20px; + } } &__container { - overflow: hidden; /* PC 슬라이드: 숨김 */ - justify-content: flex-start; + width: 100%; + overflow: visible; + + @media (max-width: 1199px) { + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + } } &__track { display: flex; gap: 16px; - transition: transform 0.3s ease; + overflow: visible; } } diff --git a/src/pages/MessagePage/MessagePage.jsx b/src/pages/MessagePage/MessagePage.jsx index 52dc58e..c4a681c 100644 --- a/src/pages/MessagePage/MessagePage.jsx +++ b/src/pages/MessagePage/MessagePage.jsx @@ -10,7 +10,7 @@ import Dropdown from '@/components/Dropdown/Dropdown'; import ProfileSelector from './components/ProfileSelector'; import styles from './MessagePage.module.scss'; import Editor from '@/components/Editor/Editor'; -import { FONT_OPTIONS, FONT_STYLES, FONT_DROPDOWN_ITEMS } from '@/constants/fontMap'; +import { FONT_OPTIONS, FONT_DROPDOWN_ITEMS } from '@/constants/fontMap'; import { useEffect, useState } from 'react'; // 상대와의 관계 옵션 @@ -127,7 +127,7 @@ function MessagePage() {
diff --git a/src/pages/RollingPaperItemPage/RollingPaperItemPage.jsx b/src/pages/RollingPaperItemPage/RollingPaperItemPage.jsx index f60e77c..14ed999 100644 --- a/src/pages/RollingPaperItemPage/RollingPaperItemPage.jsx +++ b/src/pages/RollingPaperItemPage/RollingPaperItemPage.jsx @@ -1,18 +1,17 @@ import { useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { useApi } from '@/hooks/useApi.jsx'; import { useModal } from '@/hooks/useModal'; -import { getRecipient } from '@/apis/recipientsApi'; import { useMessageItemsList } from '@/hooks/useMessageItemsList'; -import { COLOR_STYLES } from '../../constants/colorThemeStyle'; -import styles from '@/pages/RollingPaperItemPage/RollingPaperItemPage.module.scss'; -import ListButtonGroup from './components/ListButtonGroup'; -import ListCard from './components/ListCard'; -import ActionCard from './components/ActionCard'; -import CardModal from '../../components/CardModal'; -import RequestDeletePaperModal from './components/RequestDeletePaperModal'; -import DeletePaperSuccessModal from './components/DeletePaperSuccessModal'; +import { COLOR_STYLES } from '@/constants/colorThemeStyle'; +import CardModal from '@/components/CardModal'; import PostHeader from '@/components/PostHeader/PostHeader'; +import LoadingOverlay from '@/components/LoadingOverlay'; +import styles from '@/pages/RollingPaperItemPage/RollingPaperItemPage.module.scss'; +import ListCard from '@/pages/RollingPaperItemPage/components/ListCard'; +import ActionCard from '@/pages/RollingPaperItemPage/components/ActionCard'; +import ListButtonGroup from '@/pages/RollingPaperItemPage/components/ListButtonGroup'; +import RequestDeletePaperModal from '@/pages/RollingPaperItemPage/components/RequestDeletePaperModal'; +import DeletePaperSuccessModal from '@/pages/RollingPaperItemPage/components/DeletePaperSuccessModal'; import InfinityScrollWrapper from '@/components/InfinityScrollWrapper/InfinityScrollWrapper'; const RollingPaperItemPage = () => { @@ -21,24 +20,67 @@ const RollingPaperItemPage = () => { const { showModal, closeModal } = useModal(); const [isEditMode, setIsEditMode] = useState(false); - /* useApi 사용하여 API 불러오는 영역 */ - const { data: recipientData } = useApi(getRecipient, { id }, { immediate: true }); - /* 커스텀훅 영역 */ - const { itemList, hasNext, loadMore, onClickDeleteMessage, onDeletePaperConfirm } = - useMessageItemsList(id); // 리스트 데이터 API 및 동작 + const { + recipientData, + itemList, + hasNext, + showOverlay, + isLoading, + loadingDescription, + loadMore, + onClickDeleteMessage, + onDeletePaperConfirm, + } = useMessageItemsList(id); // 리스트 데이터 API 및 동작 /* 전체 배경 스타일 적용 */ - const containerStyle = { - backgroundColor: !recipientData?.backgroundImageURL - ? COLOR_STYLES[recipientData?.backgroundColor]?.primary - : '', - backgroundImage: recipientData?.backgroundImageURL - ? `url(${recipientData?.backgroundImageURL})` - : 'none', - backgroundRepeat: 'no-repeat', - backgroundPosition: 'center', - backgroundSize: 'cover', + const containerStyle = recipientData + ? { + backgroundColor: !recipientData?.backgroundImageURL + ? COLOR_STYLES[recipientData?.backgroundColor]?.primary + : '', + backgroundImage: recipientData?.backgroundImageURL + ? `url(${recipientData?.backgroundImageURL})` + : 'none', + backgroundRepeat: 'no-repeat', + backgroundPosition: 'center', + backgroundSize: 'cover', + } + : {}; + + const paperDeleteModalData = { + title: ( + <> + 정말 이 롤링페이퍼를 +
+ {' 삭제'} + 하시겠습니까? + + ), + content: ( + <> + 삭제하면 모든 메시지가 함께 삭제되며 +
+ 복구할 수 없습니다. + + ), + }; + + const messageDeleteModalData = { + title: ( + <> + 메시지를 + {' 삭제'} + 하시겠습니까? + + ), + content: ( + <> + 삭제하면 메시지가 삭제되며 +
+ 복구할 수 없습니다. + + ), }; /* 버튼, 카드 클릭 시 동작 */ @@ -63,8 +105,19 @@ const RollingPaperItemPage = () => { }; /* 메세지 삭제 */ - const handleOnClickDeleteMessage = () => { - onClickDeleteMessage(); + const handleOnClickDeleteMessage = (messageId) => { + showModal( + handleOnDeleteMessageConfirm(messageId)} + onCancel={closeModal} + modalItems={messageDeleteModalData} + />, + ); + }; + + const handleOnDeleteMessageConfirm = async (messageId) => { + onClickDeleteMessage(messageId); + closeModal(); }; /* 롤링페이퍼 페이퍼 삭제 */ @@ -73,6 +126,7 @@ const RollingPaperItemPage = () => { handleOnDeletePaperConfirm()} onCancel={closeModal} + modalItems={paperDeleteModalData} />, ); }; @@ -90,6 +144,9 @@ const RollingPaperItemPage = () => { ); }; + if (showOverlay || isLoading) { + return ; + } return ( <> {/* 헤더 영역 */} diff --git a/src/pages/RollingPaperItemPage/RollingPaperItemPage.module.scss b/src/pages/RollingPaperItemPage/RollingPaperItemPage.module.scss index 4f69345..a2b5b20 100644 --- a/src/pages/RollingPaperItemPage/RollingPaperItemPage.module.scss +++ b/src/pages/RollingPaperItemPage/RollingPaperItemPage.module.scss @@ -5,11 +5,11 @@ justify-content: center; align-items: start; - @media screen and (min-width: 768px) { + @include tablet { padding: 92px 24px; } - @media screen and (min-width: 1024px) { + @include desktop { padding: 113px 24px; } @@ -31,48 +31,13 @@ max-width: 100%; justify-items: center; - @media screen and (min-width: 768px) { + @include tablet { grid-template-columns: repeat(2, 1fr); } - @media screen and (min-width: 1024px) { + @include desktop { grid-template-columns: repeat(3, 1fr); gap: 24px; } } } - -.list__button { - left: 20px; - right: 20px; - bottom: 24px; - height: 56px; - z-index: 100; - border-radius: 12px; - background-color: var(--color-purple-600); - border: none; - display: flex; - align-items: center; - justify-content: center; - color: var(--color-white); - font-size: var(--font-size-16); - font-weight: var(--font-weight-regular); - position: fixed; - - @media screen and (min-width: 768px) { - left: 24px; - right: 24px; - bottom: 24px; - } - - @media screen and (min-width: 1024px) { - border-radius: 6px; - width: 92px; - height: 39px; - position: static; - } -} - -// .list__observer { -// height: 1px; -// } diff --git a/src/pages/RollingPaperItemPage/components/DeletePaperSuccessModal.module.scss b/src/pages/RollingPaperItemPage/components/DeletePaperSuccessModal.module.scss index 5561059..23d755c 100644 --- a/src/pages/RollingPaperItemPage/components/DeletePaperSuccessModal.module.scss +++ b/src/pages/RollingPaperItemPage/components/DeletePaperSuccessModal.module.scss @@ -1,84 +1,89 @@ +$wrapper-margin-x: 20px; + +$wrapper-width-mobile: min(350px, calc(100vw - calc($wrapper-margin-x * 2))); +$wrapper-width-tablet: 350px; +$wrapper-width-desktop: 350px; + .modal-styler { - padding: 16px; - margin: 0 20px; - width: 230px; + padding: 16px; + @include responsive-width($wrapper-width-mobile, $wrapper-width-tablet, $wrapper-width-desktop); } .header-area { - display: flex; - flex-direction: column; - align-items: center; - gap: 18px; + display: flex; + flex-direction: column; + align-items: center; + gap: 18px; } .header-icon { - aspect-ratio: 1 / 1; - width: 50px; - margin-top: 10px; + aspect-ratio: 1 / 1; + width: 50px; + margin-top: 10px; } .title { - font-weight: 700; - font-size: 20px; - color: var(--color-gray-700); - text-align: center; + font-weight: 700; + font-size: 20px; + color: var(--color-gray-700); + text-align: center; } .content { - width: 100%; - margin: 15px 0 20px; - text-align: center; - font-weight: 400; - font-size: 15px; - line-height: 20px; - color: var(--color-gray-400); + width: 100%; + margin: 15px 0 20px; + text-align: center; + font-weight: 400; + font-size: 15px; + line-height: 20px; + color: var(--color-gray-400); } .progress-bar { - margin: 0 10px 20px; + margin: 0 10px 20px; } .button-area { - display: flex; - flex-direction: row; - justify-content: center; - gap: 8px; + display: flex; + flex-direction: row; + justify-content: center; + gap: 8px; } .button__submit { - border-radius: 10px; - padding: 10px 0; - width: 100%; - text-align: center; - cursor: pointer; - font-weight: 700; - font-size: 16px; - line-height: 26px; + border-radius: 10px; + padding: 10px 0; + width: 100%; + text-align: center; + cursor: pointer; + font-weight: 700; + font-size: 16px; + line-height: 26px; - background-color: var(--color-purple-600); - border: none; - color: var(--color-white); + background-color: var(--color-purple-600); + border: none; + color: var(--color-white); - &:hover{ - background-color: var(--color-purple-700); - } + &:hover { + background-color: var(--color-purple-700); + } } .button__cancel { - border-radius: 10px; - padding: 10px 0; - width: 100%; - text-align: center; - cursor: pointer; - font-weight: 700; - font-size: 16px; - line-height: 26px; + border-radius: 10px; + padding: 10px 0; + width: 100%; + text-align: center; + cursor: pointer; + font-weight: 700; + font-size: 16px; + line-height: 26px; - background-color: #E9E9E9; - border: none; - color: var(--color-gray-600); + background-color: #e9e9e9; + border: none; + color: var(--color-gray-600); - &:hover{ - background-color: #E2E2E2; - } -} \ No newline at end of file + &:hover { + background-color: #e2e2e2; + } +} diff --git a/src/pages/RollingPaperItemPage/components/ListButtonGroup.jsx b/src/pages/RollingPaperItemPage/components/ListButtonGroup.jsx index f3d7837..2d11a5d 100644 --- a/src/pages/RollingPaperItemPage/components/ListButtonGroup.jsx +++ b/src/pages/RollingPaperItemPage/components/ListButtonGroup.jsx @@ -1,25 +1,26 @@ import styles from './ListButtonGroup.module.scss'; import Button from '@/components/Button/Button'; +import { useDeviceType } from '@/hooks/useDeviceType'; const ListButtonGroup = ({ showDelete, onClickEdit, onClickPrev, onClickGoList }) => { + const deviceType = useDeviceType(); + const buttonSize = deviceType !== 'desktop' ? 'stretch' : 'small'; + return ( <>
- + {!showDelete && ( - + )} {showDelete && ( - + )}
diff --git a/src/pages/RollingPaperItemPage/components/ListButtonGroup.module.scss b/src/pages/RollingPaperItemPage/components/ListButtonGroup.module.scss index f25b88b..3ee519a 100644 --- a/src/pages/RollingPaperItemPage/components/ListButtonGroup.module.scss +++ b/src/pages/RollingPaperItemPage/components/ListButtonGroup.module.scss @@ -2,13 +2,12 @@ display: flex; gap: 8px; position: fixed; - z-index: 100; // 논의 필요 + z-index: 10; // 논의 필요 left: 20px; right: 20px; bottom: 24px; - height: 56px; - @media screen and (min-width: 768px) { + @include tablet { left: 24px; right: 24px; bottom: 24px; @@ -22,25 +21,4 @@ .list__button { display: flex; flex: 1; - align-items: center; - justify-content: center; - border-radius: 12px; - background-color: var(--color-purple-600); - border: none; - flex: 1; - color: var(--color-white); - font-size: var(--font-size-16); - font-weight: var(--font-weight-regular); - - @media screen and (min-width: 1024px) { - border-radius: 6px; - width: 92px; - height: 39px; - } -} - -.list__button--border { - border: 1px solid var(--color-purple-600); - background: var(--color-white); - color: var(--color-purple-600); } diff --git a/src/pages/RollingPaperItemPage/components/ListCard.jsx b/src/pages/RollingPaperItemPage/components/ListCard.jsx index bb4d827..8282f92 100644 --- a/src/pages/RollingPaperItemPage/components/ListCard.jsx +++ b/src/pages/RollingPaperItemPage/components/ListCard.jsx @@ -1,10 +1,16 @@ -import styles from './ListCard.module.scss'; +import styles from '@/pages/RollingPaperItemPage/components/ListCard.module.scss'; import DeleteIcon from './DeleteIcon'; +import Button from '@/components/Button/Button'; import SenderProfile from '@/components/SenderProfile'; +import ContentViewer from '@/components/ContentViewer/ContentViewer'; const ListCard = ({ cardData, showDelete, onClick, onDelete }) => { /* 폰트 확인 후 해당 폰트로 보여줘야 함 */ - const { id, sender, profileImageURL, content, createdAt, relationship } = cardData; + const { id, sender, profileImageURL, content, createdAt, relationship, font } = cardData; + + const deleteIconStyle = { + color: 'var(--color-gray-500)', + }; const formatDateKRW = (isoString) => { const date = new Date(isoString); @@ -20,6 +26,7 @@ const ListCard = ({ cardData, showDelete, onClick, onDelete }) => { imageUrl: profileImageURL, createdAt: formatDateKRW(createdAt), content: content, + font: font, }); }; @@ -35,13 +42,21 @@ const ListCard = ({ cardData, showDelete, onClick, onDelete }) => {
{showDelete && ( - + )}

-
{content}
+
+ +
{formatDateKRW(createdAt)}
diff --git a/src/pages/RollingPaperItemPage/components/ListCard.module.scss b/src/pages/RollingPaperItemPage/components/ListCard.module.scss index 1f88f51..6c2f686 100644 --- a/src/pages/RollingPaperItemPage/components/ListCard.module.scss +++ b/src/pages/RollingPaperItemPage/components/ListCard.module.scss @@ -16,20 +16,22 @@ transform: translateZ(0); cursor: pointer; - &:hover { - transform: scale(1.05); + @media (hover: hover) { + &:hover { + transform: scale(1.05); + } } &:active { transform: scale(1.05); } - @media screen and (min-width: 768px) { + @include tablet { aspect-ratio: 352 / 284; max-width: none; } - @media screen and (min-width: 1200px) { + @include desktop { aspect-ratio: 384 / 280; } } @@ -70,7 +72,12 @@ -webkit-box-orient: vertical; line-height: 28px; - @media screen and (min-width: 768px) { + @include tablet { + -webkit-line-clamp: 4; + line-clamp: 4; + } + + @include desktop { -webkit-line-clamp: 4; line-clamp: 4; } diff --git a/src/pages/RollingPaperItemPage/components/RequestDeletePaperModal.jsx b/src/pages/RollingPaperItemPage/components/RequestDeletePaperModal.jsx index ae5a03c..53dc09c 100644 --- a/src/pages/RollingPaperItemPage/components/RequestDeletePaperModal.jsx +++ b/src/pages/RollingPaperItemPage/components/RequestDeletePaperModal.jsx @@ -2,23 +2,16 @@ import styles from './RequestDeletePaperModal.module.scss'; import Modal from '@/components/Modal'; import alertIcon from '@/assets/icons/icon_alert_gray_lg.svg'; -const RequestDeletePaperModal = ({ onConfirm, onCancel }) => { +const RequestDeletePaperModal = ({ onConfirm, onCancel, modalItems }) => { + const { title, content } = modalItems; return ( - - 정말 이 롤링페이퍼를 - {' 삭제'} - 하시겠습니까? - + {title} - - 삭제하면 모든 코멘트가 함께 삭제되며 -
- 복구할 수 없습니다. -
+ {content}