diff --git a/src/components/CountUp.jsx b/src/components/CountUp.jsx new file mode 100644 index 0000000..453734f --- /dev/null +++ b/src/components/CountUp.jsx @@ -0,0 +1,46 @@ +// src/components/CountUp/CountUp.jsx +import { useEffect, useState } from 'react'; + +/** + * 숫자 카운트 애니메이션 + * + * @param {object} props + * @param {number} props.start - 시작 값 + * @param {number} props.end - 종료 값 + * @param {number} props.duration - 애니메이션 지속시간(ms) + * @param {string} [props.className] + * @param {function} [props.format] - 값 포맷터 (예: (v)=>v.toLocaleString()) + */ +export default function CountUp({ + start = 0, + end = 100, + duration = 1000, + className = '', + format = (v) => v, +}) { + const [value, setValue] = useState(start); + useEffect(() => { + const delta = end - start; + if (delta === 0 || duration === 0) { + setValue(end); + return; + } + // 애니메이션 단계 계산 + const steps = Math.abs(delta); + const intervalMs = duration / steps; + + let current = start; + // setInterval을 사용하여 단계별로 값 업데이트 + // delta > 0: 증가, delta < 0: 감소 + const id = setInterval(() => { + current += delta > 0 ? 1 : -1; + setValue(current); + + if (current === end) clearInterval(id); + }, intervalMs); + // 컴포넌트 언마운트 시 interval 정리 + return () => clearInterval(id); + }, [start, end, duration]); + + return {format(Math.round(value))}; +} diff --git a/src/components/DropdownButton/DropdownButton.jsx b/src/components/DropdownButton/DropdownButton.jsx index 5e65a24..e703839 100644 --- a/src/components/DropdownButton/DropdownButton.jsx +++ b/src/components/DropdownButton/DropdownButton.jsx @@ -2,6 +2,7 @@ import React, { useState, useRef, useEffect } from 'react'; import { useDropdownPosition } from '@/hooks/useDropdownPosition'; import styles from './DropdownButton.module.scss'; +import classnames from 'classnames'; /** * DropdownButton 컴포넌트 @@ -19,8 +20,12 @@ import styles from './DropdownButton.module.scss'; * - 메뉴 컨테이너 영역에 추가할 클래스 이름 * @param {function} [props.onToggle] * - 열기/닫기 상태 변화 시 호출되는 콜백 (인자로 (isOpen: boolean)) - * @param {boolean} [props.openOnHover=false] - * - 토글 위에 마우스가 올라갈 때 드롭다운이 열리도록 할지 여부 + * @param {trigger} [props.trigger='click'] + * - 드롭다운 열기/닫기 트리거 방식 ('click' 또는 'hover', 'always') + * @param {number} [props.offset=4] + * - 드롭다운 메뉴 위치가 보일 간격 (px 단위) + * @param {number} [props.animationDuration=200] + * - 드롭다운 애니메이션 지속 시간 (ms 단위) */ function DropdownButton({ ToggleComponent, @@ -29,73 +34,103 @@ function DropdownButton({ ButtonClassName = '', MenuClassName = '', onToggle, - openOnHover = false, + trigger = 'click', + offset = 4, + animationDuration = 200, }) { - const [isOpen, setIsOpen] = useState(false); + const [isOpen, setIsOpen] = useState(false); // 드롭다운 열림 상태 + const [locked, setLocked] = useState(false); // 클릭 고정인지 여부 // 밖을 클릭했을 때 닫기 위한 ref const containerRef = useRef(null); const menuRef = useRef(null); // 드롭다운 위치 보정 훅 - //커스텀 훅 호출: isOpen이 true일 때마다 위치 보정값(adjustX)을 계산 const adjustXValue = useDropdownPosition(containerRef, menuRef, isOpen); - const openDropdown = () => { + /* ---------- 열고/닫기 헬퍼 ---------- */ + const open = () => { setIsOpen(true); - - onToggle && onToggle(true); + onToggle?.(true); }; - - const closeDropdown = () => { + const close = () => { setIsOpen(false); - - onToggle && onToggle(false); + onToggle?.(false); }; + /* ---------- 토글 처리 ---------- */ const handleToggleClick = () => { - setIsOpen((prev) => { - const next = !prev; - onToggle && onToggle(next); - return next; - }); - }; + /* ───────── 1) hover 모드 : 클릭 무시 ───────── */ + if (trigger === 'hover') { + return; // 아무 동작 없음 + } - // 외부 클릭 시 닫기 - useEffect(() => { - if (!isOpen) return; - const handleClickOutside = (e) => { - if (containerRef.current && !containerRef.current.contains(e.target)) { - setIsOpen(false); - onToggle && onToggle(false); + /* ───────── 2) click 모드 : 단순 토글 ───────── */ + if (trigger === 'click') { + if (isOpen) { + close(); // 이미 열려 있으면 닫기 + } else if (!isOpen) { + open(); // 닫혀 있으면 열기 } - }; - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, [isOpen, onToggle]); + return; + } - // 호버 동작: openOnHover가 true일 때만 처리 - const handleMouseEnter = () => { - if (openOnHover) { - openDropdown(); + /* ───────── 3) always 모드 ───────── */ + if (trigger === 'always') { + /* 3-1. 닫혀 있으면 : 무조건 열고 lock 해제 */ + if (!isOpen) { + setLocked(false); + open(); + return; + } + + /* 3-2. 열려 있고 lock이 해제되어 있으면 : lock 활성화 */ + if (isOpen && !locked) { + setLocked(true); // 고정 + return; + } + + /* 3-3. 열려 있고 lock이 걸려 있으면 : lock 해제 + 닫기 */ + if (isOpen && locked) { + setLocked(false); + close(); + return; + } } }; + /* ---------- hover 처리 ---------- */ + const handleMouseEnter = () => { + if (trigger === 'hover' || (trigger === 'always' && !locked)) open(); + }; const handleMouseLeave = () => { - if (openOnHover) { - closeDropdown(); - } + if (trigger === 'hover' || (trigger === 'always' && !locked)) close(); }; - const wrapperClass = [ - styles.dropdown, - styles[`dropdown--${layout}`], - isOpen ? styles['dropdown--open'] : '', - ] - .filter(Boolean) - .join(' '); + /* ---------- 외부 클릭 시 닫기 ---------- */ + useEffect(() => { + if (!isOpen || (trigger === 'always' && locked)) return; + + const handleOutside = (e) => { + if (!containerRef.current?.contains(e.target)) { + setIsOpen(false); + onToggle?.(false); + } + }; + document.addEventListener('mousedown', handleOutside); + return () => document.removeEventListener('mousedown', handleOutside); + }, [isOpen, locked, trigger, onToggle]); - const toggleClass = [styles['dropdown__toggle'], ButtonClassName].filter(Boolean).join(' '); - const menuClass = [styles['dropdown__menu'], MenuClassName].filter(Boolean).join(' '); + /* ---------- 애니메이션 시간 계산 ---------- */ + const duration = + typeof animationDuration === 'number' + ? { open: animationDuration, close: animationDuration } + : { open: animationDuration.open, close: animationDuration.close }; + /* ---------- 클래스 이름 설정 ---------- */ + const wrapperClass = classnames(styles.dropdown, styles[`dropdown--${layout}`], { + [styles['dropdown--open']]: isOpen, + }); + const toggleClass = classnames(styles['dropdown__toggle'], ButtonClassName); + const menuClass = classnames(styles['dropdown__menu'], MenuClassName); return (
{ToggleComponent}
-
- {ListComponent} -
+ {/* 드롭다운 메뉴: null 또는 undefined 경우 표시 안함 */} + {ListComponent && ( +
+ {ListComponent} +
+ )} ); } diff --git a/src/components/DropdownButton/DropdownButton.module.scss b/src/components/DropdownButton/DropdownButton.module.scss index 7b76f7c..91d048d 100644 --- a/src/components/DropdownButton/DropdownButton.module.scss +++ b/src/components/DropdownButton/DropdownButton.module.scss @@ -46,22 +46,13 @@ position: absolute; top: 100%; /* 토글 바로 아래 */ left: 50%; /* 토글의 중앙 기준으로 정렬 */ - margin-top: 4px; + padding: 8px; border-radius: 8px; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); background-color: var(--color-white); - - /* 중앙 정렬을 위해 X축으로 -50% 이동 */ transform-origin: top center; - transform: scaleY(0) translateX(-50%); - opacity: 0; pointer-events: none; - - /* 열림/닫힘 애니메이션 */ - transition: - transform 100ms ease, - opacity 100ms ease; z-index: 100; } diff --git a/src/components/GradientImage/GradientImage.jsx b/src/components/GradientImage/GradientImage.jsx index 640097b..a48bb2d 100644 --- a/src/components/GradientImage/GradientImage.jsx +++ b/src/components/GradientImage/GradientImage.jsx @@ -12,6 +12,7 @@ import styles from './GradientImage.module.scss'; * @param {string} props.src 실제 이미지 URL * @param {string} [props.alt] 대체 텍스트 * @param {string} [props.className] 추가 클래스 + * @param {Object} [props.style] 추가 스타일 * @param {Function} [props.onLoaded] 이미지 로딩 완료 시 호출될 콜백 * @param {Object} [props.rest] 기타 속성 */ @@ -19,6 +20,7 @@ export default function GradientImage({ src, alt = '', className = '', + style = {}, onClick, onLoaded, ...rest @@ -37,6 +39,7 @@ export default function GradientImage({ className={classNames(className, styles['gradient-image'], { [styles['gradient-image--loaded']]: loaded, })} + style={style} onClick={onClick} > {src && ( diff --git a/src/components/Header/GlobalHeader.jsx b/src/components/Header/GlobalHeader.jsx index ab7e6e9..4acab57 100644 --- a/src/components/Header/GlobalHeader.jsx +++ b/src/components/Header/GlobalHeader.jsx @@ -16,14 +16,15 @@ function GlobalHeader() { const VISIBLE_PATHS = ['/', '/list']; const showButton = useShowComponent(VISIBLE_PATHS); const isMobile = deviceType === DEVICE_TYPES.PHONE; - if (isMobile) { - // 모바일에서는 버튼을 숨김 - return null; - } + const handleButtonClick = () => { navigate('/post'); }; + if (isMobile && !showButton) { + // 모바일이면서 버튼을 보여주지 않는 경우 헤더를 숨김 + return null; + } return (
diff --git a/src/components/Header/GlobalHeader.module.scss b/src/components/Header/GlobalHeader.module.scss index 15f05ac..9ef3a87 100644 --- a/src/components/Header/GlobalHeader.module.scss +++ b/src/components/Header/GlobalHeader.module.scss @@ -2,6 +2,9 @@ display: flex; justify-content: center; background-color: var(--color-white); + position: sticky; + top: 0; // 뷰포트 최상단에 붙임 + z-index: 1000; &__container { display: flex; diff --git a/src/components/LoadingLabel/LoadingLabel.jsx b/src/components/LoadingLabel/LoadingLabel.jsx index 06b5bf7..1e0af50 100644 --- a/src/components/LoadingLabel/LoadingLabel.jsx +++ b/src/components/LoadingLabel/LoadingLabel.jsx @@ -13,7 +13,7 @@ import styles from './LoadingLabel.module.scss'; export default function LoadingLabel({ loading, loadingText = '로딩 중...', - loadedText = '완료', + loadedText = '', className = '', }) { return ( diff --git a/src/components/PostHeader/EmojiGroup/EmojiAdd.jsx b/src/components/PostHeader/EmojiGroup/EmojiAdd.jsx index f657015..bc994b7 100644 --- a/src/components/PostHeader/EmojiGroup/EmojiAdd.jsx +++ b/src/components/PostHeader/EmojiGroup/EmojiAdd.jsx @@ -8,6 +8,7 @@ import EmojiPicker from 'emoji-picker-react'; import Style from './EmojiAdd.module.scss'; import { createRecipientReaction } from '@/apis/recipientReactionsApi'; import { useToast } from '@/hooks/useToast'; +import Button from '@/components/Button/Button'; /** * EmojiAdd 컴포넌트 @@ -19,17 +20,17 @@ import { useToast } from '@/hooks/useToast'; * @param {object} props * @param {number|string} props.id * - 이모지를 추가할 대상 Recipient ID - * @param {() => void} [props.onSuccess] + * @param {(emoji: string) => void} [props.onSuccess] * - 이모지 추가 API 호출이 성공했을 때 실행할 콜백 (예: 반응 목록을 다시 불러오기) */ -export default function EmojiAdd({ id, onSuccess }) { +export default function EmojiAdd({ id, onSuccess, isMobile = false }) { const { showToast } = useToast(); /** * useApi 훅을 통해 createRecipientReaction API 호출을 관리합니다. * - immediate: false 로 설정하여 컴포넌트 마운트 시 자동 호출을 방지 * - refetch(params) 형태로 이모지를 선택할 때마다 호출하며, loading / error 상태를 관리 */ - const { loading, error, refetch } = useApi( + const { loading, refetch } = useApi( createRecipientReaction, { recipientId: id, emoji: '', type: 'increase' }, { @@ -55,7 +56,7 @@ export default function EmojiAdd({ id, onSuccess }) { message: emojiData.emoji + ' 이모지 추가 성공!', timer: 1000, }); - onSuccess && onSuccess(); + onSuccess && onSuccess(emojiData.emoji); }) .catch(() => { // error는 useApi 내부에서 errorMessage로 Toast 처리됨 @@ -65,16 +66,19 @@ export default function EmojiAdd({ id, onSuccess }) { /** * @todo IconButton 컴포넌트로 변경 예정 */ - const toggleButton = ( - + ); /** @@ -100,12 +104,8 @@ export default function EmojiAdd({ id, onSuccess }) { layout='column' ButtonClassName={Style['emoji-add__toggle']} MenuClassName={Style['emoji-add__menu']} + offset={20} /> - - {/* API 에러가 있다면 화면에 간단히 보여줌 */} - {error && ( -
이모지 추가 중 오류 발생: {error.message}
- )}
); } diff --git a/src/components/PostHeader/EmojiGroup/EmojiBadge.jsx b/src/components/PostHeader/EmojiGroup/EmojiBadge.jsx index 8259f4d..78ff330 100644 --- a/src/components/PostHeader/EmojiGroup/EmojiBadge.jsx +++ b/src/components/PostHeader/EmojiGroup/EmojiBadge.jsx @@ -1,21 +1,44 @@ -// src/components/EmojiGroup/EmojiBadge.jsx -import React from 'react'; +// EmojiBadge.jsx +import { useEffect, useRef, useState } from 'react'; +import CountUp from '@/components/CountUp'; +import cn from 'classnames'; import Style from './EmojiBadge.module.scss'; -/** - * EmojiBadge 컴포넌트 (단일 사이즈) - * - * @param {object} props - * @param {string} props.emoji - 화면에 표시할 이모지 기호 (예: "👍", "😍" 등) - * @param {number} props.count - 해당 이모지의 누적 개수 - * @param {string} [props.className] - 추가 클래스 - * @param {object} [props.style] - inline 스타일 - */ -export default function EmojiBadge({ emoji, count, className = '', style = {} }) { +export default function EmojiBadge({ emoji, count, addedEmoji, className = '', style = {} }) { + /* ---------- 애니메이션 제어용 ref ---------- */ + const prevCountRef = useRef(undefined); // undefined → 첫 렌더 감지 + const prev = prevCountRef.current ?? 0; // undefined 면 0으로 처리 + + /* ---------- bump (icon scale) ---------- */ + const [bump, setBump] = useState(false); + const handleEnd = () => setBump(false); + useEffect(() => { + // bump 는 오로지 addedEmoji 와 일치할 때만 + if (emoji !== addedEmoji) return; + setBump(true); + }, [addedEmoji, emoji]); + + /* ---------- prevCount 갱신 ---------- */ + useEffect(() => { + prevCountRef.current = count; // 다음 렌더에 사용할 이전 값 + }, [count]); + + /* ---------- render ---------- */ return ( -
+
{emoji} - {count} + + {/* CountUp 은 항상 prev → count 로 */} +
); } diff --git a/src/components/PostHeader/EmojiGroup/EmojiBadge.module.scss b/src/components/PostHeader/EmojiGroup/EmojiBadge.module.scss index d4fb7dc..ab36029 100644 --- a/src/components/PostHeader/EmojiGroup/EmojiBadge.module.scss +++ b/src/components/PostHeader/EmojiGroup/EmojiBadge.module.scss @@ -1,16 +1,30 @@ /* src/components/EmojiGroup/EmojiBadge.module.scss */ +@keyframes bump { + 0% { + transform: scale(1); + } + 40% { + transform: scale(1.35); + } + 100% { + transform: scale(1); + } +} .emoji-badge { display: inline-flex; align-items: center; justify-content: center; - background-color: rgba(0,0,0,0.54); - border-radius: 16px; + background: #666666; + border-radius: 32px; color: #ffffff; white-space: nowrap; font-weight: 500; gap: 2px; - padding: 8px 12px; + padding: 10px 12px; font-size: var(--font-size-16); font-weight: var(--font-weight-regular); + &--bump { + animation: bump 250ms ease-out; + } } diff --git a/src/components/PostHeader/EmojiGroup/EmojiGroup.jsx b/src/components/PostHeader/EmojiGroup/EmojiGroup.jsx index 1bcc668..1624b30 100644 --- a/src/components/PostHeader/EmojiGroup/EmojiGroup.jsx +++ b/src/components/PostHeader/EmojiGroup/EmojiGroup.jsx @@ -5,53 +5,118 @@ import DropdownButton from '@/components/DropdownButton/DropdownButton'; import ToggleEmoji from './ToggleEmoji'; import EmojiList from './EmojiList'; import Style from './EmojiGroup.module.scss'; -import { useEffect } from 'react'; +import { useEffect, useState, useRef } from 'react'; +import LoadingLabel from '@/components/LoadingLabel/LoadingLabel'; +import EmojiAdd from './EmojiAdd'; +import { DEVICE_TYPES } from '@/constants/deviceType'; +import { useDeviceType } from '@/hooks/useDeviceType'; /** - * EmojiGroup 컴포넌트 + * 🎉 EmojiGroup + * ------------------------------------------- + * • 상위 8개의 이모지 반응을 보여주는 드롭다운 + * • 새 이모지 추가 시 낙관적 업데이트 + 백엔드 동기화 * * @param {object} props - * @param {number|string} props.id - * - 수신자(롤링페이퍼) ID + * @param {number|string} props.id 롤링페이퍼(Recipient) ID */ -export default function EmojiGroup({ id, refreshKey }) { - const { data, loading, error, refetch } = useApi( +export default function EmojiGroup({ id }) { + /* -------------------------- State -------------------------- */ + /** 서버에서 받아온 이모지들을 보관 + 낙관적 업데이트 적용용 */ + const [emojiList, setEmojiList] = useState([]); + // 새로 추가된 이모지 + const [addedEmoji, setAddedEmoji] = useState(null); + /** 드롭다운 열림 여부 – ToggleEmoji에 전달해 화살표 회전 등에 사용 */ + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + /** 이모지 목록이 한 번이라도 불러와졌는지 여부 – 초기 로딩 시 UI 표시용 */ + const hasFetchedOnce = useRef(false); + + /* 디바이스 타입에 따라 최대 표시 개수 조정 */ + const deviceType = useDeviceType(); + const isMobile = deviceType === DEVICE_TYPES.PHONE; + const isTablet = deviceType === DEVICE_TYPES.TABLET; + const MAX_COUNT = isTablet || isMobile ? 6 : 8; + + /* -------------------------- API -------------------------- */ + const { data, loading, refetch } = useApi( listRecipientReactions, - { recipientId: id, limit: 8, offset: 0 }, + { recipientId: id, limit: MAX_COUNT, offset: 0 }, { errorMessage: '이모지 반응을 불러오는 데 실패했습니다.' }, ); - const topEmojis = data?.results || []; // 최대 8개 이모지 반응 리스트 - - // 이모지 반응 목록을 새로고침하기 위한 useEffect + // -------------------------- Effect -------------------------- */ useEffect(() => { - refetch(); - }, [refreshKey, refetch]); - - // 로딩 / 에러 / 빈 상태 처리 - if (loading) { - return
이모지 로딩 중...
; - } - if (error) { - return
이모지 불러오기 실패 ㅠㅠ
; - } - if (!topEmojis.length) { - return
반응을 추가해보세요!
; - } - - // 드롭다운 버튼에 ToggleComponent, ListComponent 넘김 + if (!loading) hasFetchedOnce.current = true; + }, [loading]); + /* 서버 데이터 => 로컬 상태 초기화 / 동기화 */ + useEffect(() => { + if (data?.results) { + setEmojiList(data.results); + } + }, [data]); + + /** + * EmojiAdd 가 성공적으로 POST한 뒤 호출 + * 즉시 UI에 반영(낙관적 업데이트)하고, 백그라운드 refetch + * + * @param {string} addedEmoji 추가된 이모지 문자열 + */ + const handleAddSuccess = (newEmoji) => { + //bump 트리거용: 새로 추가된 이모지 저장 + setAddedEmoji(newEmoji); + // 낙관적 업데이트: 새 이모지 추가 + setEmojiList((prev) => { + const copy = [...prev]; + // 이미 존재하는 이모지인지 확인 + const targetIdx = copy.findIndex((e) => e.emoji === newEmoji); + // 이미 존재하는 이모지라면 count만 증가 + if (targetIdx > -1) { + copy[targetIdx] = { ...copy[targetIdx], count: copy[targetIdx].count + 1 }; + } else { + // 새로운 이모지라면 추가 + copy.push({ id: Date.now(), emoji: newEmoji, count: 1 }); + } + // count 기준 내림차순 정렬 후 최대 MAX_COUNT 개수만 유지 + return copy.sort((a, b) => b.count - a.count).slice(0, MAX_COUNT); + }); + + refetch(); // 백그라운드에서 실제 값 동기화 + }; + + // 드롭다운 열림/닫힘 상태 변경 핸들러 + const handleDropdown = (isOpen) => { + setIsDropdownOpen(isOpen); + }; + return (
- } - // ListComponent: 상위 8개 이모지를 나열 - ListComponent={} - layout='row' - ButtonClassName={Style['emoji-group__toggle']} - MenuClassName={Style['emoji-group__menu']} - openOnHover={true} - /> + {/* 처음 한번만 로딩 중 표시 */} + {loading && !hasFetchedOnce.current ? ( + + ) : emojiList.length === 0 ? ( +
반응을 추가해보세요 😍
+ ) : ( + + } + // ListComponent: 상위 8개 이모지를 나열(태블릿은 6개) + ListComponent={} + layout='row' + ButtonClassName={Style['emoji-group__toggle']} + MenuClassName={Style['emoji-group__menu']} + trigger='always' + offset={20} + onToggle={handleDropdown} + /> + )} + +
); } diff --git a/src/components/PostHeader/EmojiGroup/EmojiGroup.module.scss b/src/components/PostHeader/EmojiGroup/EmojiGroup.module.scss index 889295e..004f07f 100644 --- a/src/components/PostHeader/EmojiGroup/EmojiGroup.module.scss +++ b/src/components/PostHeader/EmojiGroup/EmojiGroup.module.scss @@ -1,8 +1,9 @@ /* src/components/EmojiGroup/EmojiGroup.module.scss */ .emoji-group { - display: inline-block; - position: relative; + display: flex; + align-items: center; + gap: 8px; } /* 토글 버튼 래퍼 */ @@ -13,20 +14,32 @@ /* 드롭다운 메뉴 컨테이너 (최대 8개 이모지를 그리드로) */ .emoji-group__menu { - width: 320px; /* 적절히 조절 가능 */ + width: 312px; margin-top: 8px; background: var(--color-white); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); padding: 8px; z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + + @include tablet { + width: 100%; + } + @include mobile { + width: 100%; + } } /* 로딩, 에러, 빈 상태 표시 */ .emoji-group--loading, .emoji-group--error, .emoji-group--empty { - padding: 12px; - font-size: 14px; - color: #555; - text-align: center; + width: 200px; + display: flex; + align-items: center; + font-size: var(--font-size-18); + color: var(--color-gray-900); + padding-right: 24px; } diff --git a/src/components/PostHeader/EmojiGroup/EmojiList.module.scss b/src/components/PostHeader/EmojiGroup/EmojiList.module.scss index f13531d..3926ea6 100644 --- a/src/components/PostHeader/EmojiGroup/EmojiList.module.scss +++ b/src/components/PostHeader/EmojiGroup/EmojiList.module.scss @@ -7,5 +7,13 @@ - 총 8개라면 자동으로 두 번째 행으로 넘어감 */ grid-template-columns: repeat(4, 1fr); gap: 10px; - padding: 24px; + padding: 20px; + + @include tablet { + grid-template-columns: repeat(3, 1fr); /* 태블릿에서는 3열 */ + } + @include mobile { + grid-template-columns: repeat(3, 1fr); /* 모바일에서는 3열 */ + padding: 16px; + } } diff --git a/src/components/PostHeader/EmojiGroup/ToggleEmoji.jsx b/src/components/PostHeader/EmojiGroup/ToggleEmoji.jsx index 71e580c..9df8ad4 100644 --- a/src/components/PostHeader/EmojiGroup/ToggleEmoji.jsx +++ b/src/components/PostHeader/EmojiGroup/ToggleEmoji.jsx @@ -2,7 +2,7 @@ import React from 'react'; import EmojiBadge from './EmojiBadge'; import Style from './ToggleEmoji.module.scss'; -import arrowDown from '@/assets/icons/arrow_down.svg'; // SVG 아이콘 임포트 +import DropdownIcon from '@/components/Dropdown/DropdownIcon'; /** * ToggleEmoji 컴포넌트 @@ -11,7 +11,7 @@ import arrowDown from '@/assets/icons/arrow_down.svg'; // SVG 아이콘 임포 * @param {Array<{ id: number, emoji: string, count: number }>} props.emojis * - 백엔드에서 count 내림차순으로 이미 정렬된 최대 8개의 이모지 리스트 */ -export default function ToggleEmoji({ emojis }) { +export default function ToggleEmoji({ emojis, open = false, addedEmoji }) { // 1) 상위 3개만 보여주기(겹치지 않음) const visibleCount = Math.min(emojis.length, 3); const visibleEmojis = emojis.slice(0, visibleCount); @@ -25,11 +25,17 @@ export default function ToggleEmoji({ emojis }) { count={item.count} size='small' className={Style['toggle-emoji__badge']} + addedEmoji={addedEmoji} /> ))} {/* 드롭다운 화살표 아이콘 (SVG) */} - ▼ +
); } diff --git a/src/components/PostHeader/EmojiGroup/ToggleEmoji.module.scss b/src/components/PostHeader/EmojiGroup/ToggleEmoji.module.scss index 16f4393..43c93d6 100644 --- a/src/components/PostHeader/EmojiGroup/ToggleEmoji.module.scss +++ b/src/components/PostHeader/EmojiGroup/ToggleEmoji.module.scss @@ -5,10 +5,11 @@ align-items: center; gap: 8px; /* 각 뱃지 간격 */ - &__arrow { - margin-left: 4px; - width: 16px; - height: 16px; - cursor: pointer; + &__button-icon { + transition: transform 0.3s ease; + + &--open { + transform: rotate(180deg); + } } } diff --git a/src/components/PostHeader/PostHeader.jsx b/src/components/PostHeader/PostHeader.jsx index 29422b8..397ac6d 100644 --- a/src/components/PostHeader/PostHeader.jsx +++ b/src/components/PostHeader/PostHeader.jsx @@ -1,6 +1,5 @@ // src/components/PostHeader/PostHeader.jsx -import React, { useState } from 'react'; import Style from './PostHeader.module.scss'; import ProfileGroup from '@/components/PostHeader/ProfileGroup/ProfileGroup'; @@ -18,10 +17,6 @@ import { useKakaoShare } from '../../hooks/useKakaoShare'; * @param {{ id: number|string, name: string }} props */ export default function PostHeader({ id, name }) { - // 이모지 추가 성공 시 EmojiGroup 새로고침을 위한 state - const [refreshKey, setRefreshKey] = useState(0); - const handleAddSuccess = () => setRefreshKey((prev) => prev + 1); - // 현재 디바이스 타입을 가져옴 const deviceType = useDeviceType(); const isDesktop = deviceType === DEVICE_TYPES.DESKTOP; @@ -34,7 +29,7 @@ export default function PostHeader({ id, name }) {

To. {name}

- + +
+
); diff --git a/src/components/PostHeader/PostHeader.module.scss b/src/components/PostHeader/PostHeader.module.scss index f62e26b..20d5cd1 100644 --- a/src/components/PostHeader/PostHeader.module.scss +++ b/src/components/PostHeader/PostHeader.module.scss @@ -3,6 +3,13 @@ .post-header { background-color: var(--color-white); box-shadow: 0 1px 4px var(--color-gray-200); + position: sticky; + top: 64px; // 뷰포트 최상단에 붙임 + z-index: 1000; + + @include mobile { + top: 0; + } &__container { display: flex; @@ -32,7 +39,11 @@ &__emoji-container { display: flex; align-items: center; - gap: 8px; + } + &__reaction-container { + display: flex; + align-items: center; + gap: 13px; } &__divider { @@ -61,7 +72,7 @@ &__menu-container { width: 100%; // 가로 전체 너비 height: 52px; - justify-content: space-between; // 왼쪽 정렬 + justify-content: start; // 중앙 정렬 margin: 0 auto; // 중앙 정렬 gap: 12px; // 아이템 간격 축소 } diff --git a/src/components/PostHeader/ProfileGroup/ProfileGroup.jsx b/src/components/PostHeader/ProfileGroup/ProfileGroup.jsx index aba2187..467a5dc 100644 --- a/src/components/PostHeader/ProfileGroup/ProfileGroup.jsx +++ b/src/components/PostHeader/ProfileGroup/ProfileGroup.jsx @@ -24,13 +24,6 @@ export default function ProfileGroup({ id }) { return [...messages].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); }, [data]); - // 프로필 이미지 업데이트(..굳이인가?) - // const handleToggle = (open) => { - // if (open) { - // refetch(); - // } - // }; - return (
} - ListComponent={} + ListComponent={ + totalCount > 0 ? ( + + ) : null + } layout='column' - openOnHover={true} + trigger='always' + offset={20} ButtonClassName={Style['profile-group__toggle-button']} MenuClassName={Style['profile-group__menu-container']} //onToggle={handleToggle} diff --git a/src/components/PostHeader/ProfileGroup/ProfileGroup.module.scss b/src/components/PostHeader/ProfileGroup/ProfileGroup.module.scss index 6cd99e9..5ecc870 100644 --- a/src/components/PostHeader/ProfileGroup/ProfileGroup.module.scss +++ b/src/components/PostHeader/ProfileGroup/ProfileGroup.module.scss @@ -7,7 +7,7 @@ } &__menu-container { - width: 200px; + width: 220px; max-height: 300px; overflow-y: auto; } diff --git a/src/components/PostHeader/ProfileGroup/ProfileList.jsx b/src/components/PostHeader/ProfileGroup/ProfileList.jsx index 70a8101..a3a9d8f 100644 --- a/src/components/PostHeader/ProfileGroup/ProfileList.jsx +++ b/src/components/PostHeader/ProfileGroup/ProfileList.jsx @@ -2,6 +2,7 @@ import React from 'react'; import Style from './ProfileList.module.scss'; import { getDaysAgo } from '@/utils/getDaysAgo.js'; +import GradientImage from '@/components/GradientImage/GradientImage'; /** * ProfileList 컴포넌트 @@ -14,24 +15,25 @@ export default function ProfileList({ profiles, loading, error }) { return
에러 발생: {error.message}
; } - const top10 = profiles.slice(0, 10); - if (top10.length === 0) { + const top5 = profiles.slice(0, 5); + if (top5.length === 0) { return
등록된 프로필이 없습니다.
; } return (
    - {top10.map((profile) => { + 최근 메시지 프로필 + {top5.map((profile) => { // 이름이 10자를 넘으면 잘라서 "..." 붙이기 const displayName = - profile.sender.length > 10 ? profile.sender.slice(0, 10) + '...' : profile.sender; + profile.sender.length > 6 ? profile.sender.slice(0, 6) + '...' : profile.sender; // getDaysAgo 유틸 함수로 “n일 전” 또는 “오늘” 계산 const timeLabel = getDaysAgo(profile.createdAt); return (
  • - {profile.sender} 999 ? '999+' : totalCount; - // 로딩 상태 - if (loading) { - return ( -
    -
    -
    -
    - 0명이 작성했어요! -
    - ); - } - // 에러 상태 if (error) { return
    오류 발생
    ; } - // 작성자 수가 0명일 때 - if (totalCount === 0) { - return ( -
    - 0명이 작성했어요! -
    - ); - } - // 실제 프로필이 1명 이상일 때 const visibleCount = Math.min(totalCount, 3); let extraCount = totalCount > 3 ? totalCount - 3 : 0; @@ -57,7 +40,7 @@ export default function ToggleAvatars({ profiles, totalCount, loading, error }) const marginRight = idx === visibleCount - 1 ? 0 : -16; const zIndex = idx + 1; return ( - {profile.sender} 0 &&
    +{displayExtra}
    } - - - {displayCount}명이 - 작성했어요! - + {loading ? ( + + ) : totalCount === 0 ? ( + + 마음을 담은 메시지를 보내주세요! + + ) : ( + + + 명이 작성했어요! + + )}
    ); } diff --git a/src/components/PostHeader/ProfileGroup/ToggleAvatars.module.scss b/src/components/PostHeader/ProfileGroup/ToggleAvatars.module.scss index a96bffa..d937149 100644 --- a/src/components/PostHeader/ProfileGroup/ToggleAvatars.module.scss +++ b/src/components/PostHeader/ProfileGroup/ToggleAvatars.module.scss @@ -10,17 +10,17 @@ opacity: 0; animation: fade-in-spinner 2s forwards; } - +.toggle-avatars--loading { + display: flex; + align-items: center; + font-size: var(--font-size-18); + color: var(--color-gray-900); +} .toggle-avatars--error { font-size: 14px; color: #e53e3e; } -.toggle-avatars--empty { - display: flex; - align-items: center; -} - .toggle-avatars__avatar { width: 28px; height: 28px; @@ -50,6 +50,18 @@ font-size: var(--font-size-18); color: var(--color-gray-900); } +.toggle-avatars--empty { + display: flex; + align-items: center; + font-size: var(--font-size-18); + color: var(--color-gray-900); + @include tablet { + display: none; /* 태블릿 이상에서는 숨김 */ + } + @include mobile { + display: none; /* 모바일에서는 숨김 */ + } +} .toggle-avatars__count-number { font-weight: bold; color: var(--color-black); diff --git a/src/components/PostHeader/ShareMenu/ShareMenu.jsx b/src/components/PostHeader/ShareMenu/ShareMenu.jsx index 425b71f..93e176d 100644 --- a/src/components/PostHeader/ShareMenu/ShareMenu.jsx +++ b/src/components/PostHeader/ShareMenu/ShareMenu.jsx @@ -3,6 +3,7 @@ import ShareIcon from '@/assets/icons/share-20.svg'; // SVG를 URL로 import 한 import Style from './ShareMenu.module.scss'; import { useCallback } from 'react'; import { useToast } from '@/hooks/useToast'; +import Button from '@/components/Button/Button'; /** * * 공유 아이콘(버튼)을 클릭했을 때 아래 두 가지 메뉴가 표시됩니다. @@ -40,13 +41,7 @@ export default function ShareMenu({ onKakaoClick }) { // 토글 버튼(Share 아이콘) const toggleButton = ( - /** - * @todo 디자인시스템 버튼으로 교체 - */ - +