);
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 (
-
+
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) => (
-

- ))}
-
- {/* 나머지 작성자 수 +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) => (
+

+ ))}
+
+ {/* +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(
+