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 }) {