diff --git a/src/components/common/CommentBottomSheet/GlobalCommentBottomSheet.styled.ts b/src/components/common/CommentBottomSheet/GlobalCommentBottomSheet.styled.ts index 487af12e..b28e98da 100644 --- a/src/components/common/CommentBottomSheet/GlobalCommentBottomSheet.styled.ts +++ b/src/components/common/CommentBottomSheet/GlobalCommentBottomSheet.styled.ts @@ -50,6 +50,26 @@ export const Content = styled.div` flex: 1; overflow-y: auto; min-height: 0; + + scrollbar-width: thin; + scrollbar-color: ${colors.grey[100]} transparent; + + &::-webkit-scrollbar { + width: 4px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: ${colors.grey[100]}; + border-radius: 2px; + } + + &::-webkit-scrollbar-thumb:hover { + background: ${colors.white}; + } `; export const LoadingState = styled.div` diff --git a/src/components/common/CommentBottomSheet/GlobalCommentBottomSheet.tsx b/src/components/common/CommentBottomSheet/GlobalCommentBottomSheet.tsx index b0696e99..19d382e4 100644 --- a/src/components/common/CommentBottomSheet/GlobalCommentBottomSheet.tsx +++ b/src/components/common/CommentBottomSheet/GlobalCommentBottomSheet.tsx @@ -1,8 +1,7 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useParams } from 'react-router-dom'; import MessageInput from '@/components/today-words/MessageInput'; import ReplyList from '@/components/common/Post/ReplyList'; -import { getComments, type CommentData } from '@/api/comments/getComments'; import { postReply } from '@/api/comments/postReply'; import { useReplyActions } from '@/hooks/useReplyActions'; import { useReplyStore } from '@/stores/replyStore'; @@ -16,7 +15,6 @@ import { Header, Title, Content, - LoadingState, InputSection, } from './GlobalCommentBottomSheet.styled'; @@ -24,36 +22,16 @@ const GlobalCommentBottomSheet = () => { const { roomId } = useParams<{ roomId: string }>(); const { isOpen, postId, postType, closeCommentBottomSheet } = useCommentBottomSheetStore(); - const [commentList, setCommentList] = useState([]); const [inputValue, setInputValue] = useState(''); - const [isLoading, setIsLoading] = useState(false); const [isSending, setIsSending] = useState(false); const [roomCompleted, setRoomCompleted] = useState(false); + const [replyReloadKey, setReplyReloadKey] = useState(0); + const contentRef = useRef(null); const { nickname, isReplying, cancelReply } = useReplyActions(); const { parentId } = useReplyStore(); const { openSnackbar } = usePopupActions(); - const loadComments = useCallback(async () => { - if (!isOpen || !postId || !postType) return; - - setIsLoading(true); - try { - const response = await getComments(postId, { - postType, - size: 20, - }); - - if (response.data) { - setCommentList(response.data.commentList); - } - } catch (error) { - console.error('댓글 로드 실패:', error); - } finally { - setIsLoading(false); - } - }, [isOpen, postId, postType]); - const handleSendComment = async () => { if (!inputValue.trim() || isSending || !postId || !postType) return; @@ -71,7 +49,7 @@ const GlobalCommentBottomSheet = () => { if (response.isSuccess) { setInputValue(''); cancelReply(); - await loadComments(); + setReplyReloadKey(prev => prev + 1); } else { openSnackbar({ message: response.message || '댓글 작성 중 오류가 발생했습니다.', @@ -121,17 +99,11 @@ const GlobalCommentBottomSheet = () => { } }, [isOpen, roomId]); - useEffect(() => { - if (isOpen) { - loadComments(); - } - }, [isOpen, postId, loadComments]); - useEffect(() => { if (!isOpen) { setInputValue(''); cancelReply(); - setCommentList([]); + setReplyReloadKey(0); } }, [isOpen, cancelReply]); @@ -144,12 +116,16 @@ const GlobalCommentBottomSheet = () => { 댓글 - - {isLoading ? ( - 댓글을 불러오는 중... - ) : ( - - )} + + {postId && postType ? ( + + ) : null} {!roomCompleted && ( diff --git a/src/components/common/Post/PostBody.styled.ts b/src/components/common/Post/PostBody.styled.ts index 01e7d151..38de0e78 100644 --- a/src/components/common/Post/PostBody.styled.ts +++ b/src/components/common/Post/PostBody.styled.ts @@ -22,8 +22,9 @@ export const PostContent = styled.div<{ hasImage: boolean }>` font-weight: var(--string-weight-regular, 400); line-height: var(--string-lineheight-feedcontent_height20, 20px); cursor: pointer; - white-space: pre-wrap; // 개행문자 유지 - /* word-wrap: break-word; // 긴 텍스트 줄바꿈 */ + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; display: -webkit-box; -webkit-box-orient: vertical; diff --git a/src/components/common/Post/ReplyList.styled.ts b/src/components/common/Post/ReplyList.styled.ts index dbb0fa1e..975d1906 100644 --- a/src/components/common/Post/ReplyList.styled.ts +++ b/src/components/common/Post/ReplyList.styled.ts @@ -1,7 +1,7 @@ import styled from '@emotion/styled'; import { typography, colors } from '@/styles/global/global'; -export const Container = styled.div` +export const Container = styled.div<{ disableBottomMargin?: boolean }>` display: flex; flex-direction: column; width: 100%; @@ -9,7 +9,7 @@ export const Container = styled.div` max-width: 540px; */ padding: 40px 20px; margin: 0 auto; - margin-bottom: 56px; + margin-bottom: ${({ disableBottomMargin }) => (disableBottomMargin ? '0' : '56px')}; gap: 24px; flex: 1; @@ -20,7 +20,7 @@ export const Container = styled.div` } `; -export const EmptyState = styled.div` +export const EmptyState = styled.div<{ disableBottomMargin?: boolean }>` display: flex; flex-direction: column; align-items: center; @@ -30,7 +30,7 @@ export const EmptyState = styled.div` max-width: 540px; padding: 40px 20px; margin: 0 auto; - margin-bottom: 56px; + margin-bottom: ${({ disableBottomMargin }) => (disableBottomMargin ? '0' : '56px')}; gap: 8px; flex: 1; diff --git a/src/components/common/Post/ReplyList.tsx b/src/components/common/Post/ReplyList.tsx index ac48bef9..87960af1 100644 --- a/src/components/common/Post/ReplyList.tsx +++ b/src/components/common/Post/ReplyList.tsx @@ -1,37 +1,95 @@ +import { useCallback } from 'react'; +import type { RefObject } from 'react'; import Reply from './Reply'; import SubReply from './SubReply'; -import type { CommentData } from '@/api/comments/getComments'; +import { getComments, type CommentData } from '@/api/comments/getComments'; +import { useInifinieScroll } from '@/hooks/useInifinieScroll'; +import LoadingSpinner from '../LoadingSpinner'; import { Container, EmptyState } from './ReplyList.styled'; interface ReplyListProps { - commentList: CommentData[]; - onReload: () => void; + commentList?: CommentData[]; + onReload?: () => void; + postId?: number; + postType?: 'FEED' | 'RECORD' | 'VOTE'; + reloadKey?: string; + rootRef?: RefObject; + disableBottomMargin?: boolean; } -const ReplyList = ({ commentList, onReload }: ReplyListProps) => { - const hasComments = commentList.length > 0; +const ReplyList = ({ + commentList = [], + onReload, + postId, + postType, + reloadKey = '', + rootRef, + disableBottomMargin = false, +}: ReplyListProps) => { + const isInfiniteMode = Boolean(postId && postType); + const commentFeed = useInifinieScroll({ + enabled: isInfiniteMode, + reloadKey: `${postId ?? ''}-${postType ?? ''}-${reloadKey}`, + fetchPage: async cursor => { + if (!postId || !postType) { + return { items: [], nextCursor: null, isLast: true }; + } + + const response = await getComments(postId, { + postType, + size: 20, + cursor, + }); + + return { + items: response.data.commentList, + nextCursor: response.data.nextCursor || null, + isLast: response.data.isLast, + }; + }, + rootRef, + rootMargin: '120px 0px', + threshold: 0.1, + }); + + const list = isInfiniteMode ? commentFeed.items : commentList; + const hasComments = list.length > 0; + + const handleReload = useCallback(() => { + if (isInfiniteMode) { + void commentFeed.reload(); + return; + } + onReload?.(); + }, [commentFeed, isInfiniteMode, onReload]); return ( - + + {isInfiniteMode && commentFeed.isLoading && list.length === 0 && ( + + )} {hasComments ? ( - commentList.map((comment, commentIndex) => ( + list.map((comment, commentIndex) => (
- + {comment.replyList.map((sub, replyIndex) => ( ))}
)) ) : ( - +
아직 댓글이 없어요
첫번째 댓글을 남겨보세요
)} + + {isInfiniteMode && !commentFeed.isLast &&
} + {isInfiniteMode && commentFeed.isLoadingMore && } ); }; diff --git a/src/components/group/MyGroupModal.tsx b/src/components/group/MyGroupModal.tsx index 5f27c46a..affa6f8a 100644 --- a/src/components/group/MyGroupModal.tsx +++ b/src/components/group/MyGroupModal.tsx @@ -6,6 +6,8 @@ import { GroupCard } from './GroupCard'; import { Modal, Overlay } from './Modal.styles'; import { getMyRooms, type Room, type RoomType } from '@/api/rooms/getMyRooms'; import { useNavigate } from 'react-router-dom'; +import LoadingSpinner from '../common/LoadingSpinner'; +import { useInifinieScroll } from '@/hooks/useInifinieScroll'; import { TabContainer, Tab, @@ -30,12 +32,7 @@ export const MyGroupModal = ({ onClose }: MyGroupModalProps) => { }, []); const navigate = useNavigate(); const [selected, setSelected] = useState<'진행중' | '모집중' | '완료' | ''>(''); - const [rooms, setRooms] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const [nextCursor, setNextCursor] = useState(null); - const [isLast, setIsLast] = useState(false); - const contentRef = useRef(null); + const contentRef = useRef(null); const convertRoomToGroup = (room: Room): Group => { return { @@ -52,103 +49,33 @@ export const MyGroupModal = ({ onClose }: MyGroupModalProps) => { }; }; - useEffect(() => { - const fetchRooms = async () => { - try { - setIsLoading(true); - setError(null); - setNextCursor(null); - setIsLast(false); - - const roomType: RoomType = - selected === '진행중' - ? 'playing' - : selected === '모집중' - ? 'recruiting' - : selected === '완료' - ? 'expired' - : 'playingAndRecruiting'; - - const response = await getMyRooms(roomType, null); - - if (response.isSuccess) { - setRooms(response.data.roomList); - setNextCursor(response.data.nextCursor); - setIsLast(response.data.isLast); - } else { - setError(response.message); - } - } catch (error) { - console.error('방 목록 조회 실패:', error); - setError('방 목록을 불러오는데 실패했습니다.'); - } finally { - setIsLoading(false); - } - }; - - fetchRooms(); - }, [selected]); - - const isFetchingRef = useRef(false); - - const loadMore = async () => { - if (isFetchingRef.current || isLast || !nextCursor) return; - - isFetchingRef.current = true; - setIsLoading(true); - try { - const roomType: RoomType = - selected === '진행중' - ? 'playing' - : selected === '모집중' - ? 'recruiting' - : selected === '완료' - ? 'expired' - : 'playingAndRecruiting'; - - const res = await getMyRooms(roomType, nextCursor); - if (res.isSuccess) { - setRooms(prev => [...prev, ...res.data.roomList]); - setNextCursor(res.data.nextCursor); - setIsLast(res.data.isLast); - } else { - setError(res.message); - } - } catch (e) { - console.log(e); - setError('방 목록을 불러오는데 실패했습니다.'); - } finally { - setIsLoading(false); - isFetchingRef.current = false; - } - }; - - const handleScroll = async (e: React.UIEvent) => { - const el = e.currentTarget; - const { scrollTop, scrollHeight, clientHeight } = el; - if (scrollHeight - scrollTop - clientHeight < 100) { - await loadMore(); - } - }; - - useEffect(() => { - const tryFill = async () => { - if (!contentRef.current || isLast) return; - let guard = 2; - while ( - guard-- > 0 && - contentRef.current && - contentRef.current.scrollHeight <= contentRef.current.clientHeight && - !isLast && - nextCursor - ) { - await loadMore(); - await new Promise(requestAnimationFrame); + const roomType: RoomType = + selected === '진행중' + ? 'playing' + : selected === '모집중' + ? 'recruiting' + : selected === '완료' + ? 'expired' + : 'playingAndRecruiting'; + + const roomList = useInifinieScroll({ + enabled: true, + reloadKey: roomType, + rootRef: contentRef, + fetchPage: async cursor => { + const response = await getMyRooms(roomType, cursor); + if (!response.isSuccess) { + throw new Error(response.message || '방 목록을 불러오는데 실패했습니다.'); } - }; - tryFill(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [rooms, nextCursor, isLast]); + return { + items: response.data.roomList, + nextCursor: response.data.nextCursor, + isLast: response.data.isLast, + }; + }, + rootMargin: '100px 0px', + threshold: 0.1, + }); useEffect(() => { if (contentRef.current) { @@ -156,7 +83,7 @@ export const MyGroupModal = ({ onClose }: MyGroupModalProps) => { } }, [selected]); - const convertedGroups = rooms.map(convertRoomToGroup); + const convertedGroups = roomList.items.map(convertRoomToGroup); const handleGroupCardClick = (group: Group) => { if (selected === '완료') { @@ -194,8 +121,8 @@ export const MyGroupModal = ({ onClose }: MyGroupModalProps) => { ))} - - {error && {error}} + + {roomList.error && {roomList.error}} {convertedGroups.map(group => ( { /> ))} - {isLoading && 불러오는 중…} + {!roomList.isLast && ( +
+ )} + {roomList.isLoadingMore && ( + + + + )} - {!isLoading && convertedGroups.length === 0 && ( + {!roomList.isLoading && convertedGroups.length === 0 && !roomList.error && ( {selected === '진행중' diff --git a/src/components/search/GroupSearchResult.styled.ts b/src/components/search/GroupSearchResult.styled.ts index f337879c..56d3565c 100644 --- a/src/components/search/GroupSearchResult.styled.ts +++ b/src/components/search/GroupSearchResult.styled.ts @@ -73,7 +73,7 @@ export const EmptySubText = styled.p` text-align: center; `; -export const LoadingText = styled.p` +export const LoadingText = styled.div` color: ${colors.grey[100]}; font-size: ${typography.fontSize.sm}; text-align: center; diff --git a/src/components/search/GroupSearchResult.tsx b/src/components/search/GroupSearchResult.tsx index a7ee1043..3ddc3f9b 100644 --- a/src/components/search/GroupSearchResult.tsx +++ b/src/components/search/GroupSearchResult.tsx @@ -1,7 +1,9 @@ import { useMemo } from 'react'; +import type { RefObject } from 'react'; import { GroupCard } from '../group/GroupCard'; import { Filter } from '../common/Filter'; import type { SearchRoomItem } from '@/api/rooms/getSearchRooms'; +import LoadingSpinner from '../common/LoadingSpinner'; import { TabContainer, Tab, @@ -26,7 +28,7 @@ interface Props { isLoading: boolean; isLoadingMore?: boolean; hasMore?: boolean; - lastRoomElementCallback?: (node: HTMLDivElement | null) => void; + sentinelRef?: RefObject; error: string | null; selectedFilter: string; setSelectedFilter: (v: string) => void; @@ -53,7 +55,8 @@ const GroupSearchResult = ({ rooms, isLoading, isLoadingMore = false, - lastRoomElementCallback, + hasMore = false, + sentinelRef, error, selectedFilter, setSelectedFilter, @@ -117,12 +120,16 @@ const GroupSearchResult = ({ isOngoing={false} isFirstCard={type === 'searching' && idx === 0} onClick={() => onClickRoom(Number(group.id))} - ref={idx === mapped.length - 1 ? lastRoomElementCallback : undefined} /> )) )} - {isLoadingMore && mapped.length > 0 && 불러오는 중...} + {hasMore &&
} + {isLoadingMore && mapped.length > 0 && ( + + + + )} ); diff --git a/src/hooks/useInifinieScroll.ts b/src/hooks/useInifinieScroll.ts new file mode 100644 index 00000000..c8033b53 --- /dev/null +++ b/src/hooks/useInifinieScroll.ts @@ -0,0 +1,125 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { RefObject } from 'react'; + +interface PageResult { + items: T[]; + nextCursor: string | null; + isLast: boolean; +} + +interface UseInifinieScrollOptions { + enabled: boolean; + reloadKey: string; + fetchPage: (cursor: string | null) => Promise>; + mergeItems?: (prev: T[], next: T[]) => T[]; + rootRef?: RefObject; + rootMargin?: string; + threshold?: number; +} + +export const useInifinieScroll = ({ + enabled, + reloadKey, + fetchPage, + mergeItems, + rootRef, + rootMargin = '200px 0px', + threshold = 0, +}: UseInifinieScrollOptions) => { + const [items, setItems] = useState([]); + const [nextCursor, setNextCursor] = useState(null); + const [isLast, setIsLast] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [error, setError] = useState(null); + + const sentinelRef = useRef(null); + const isFetchingRef = useRef(false); + const fetchPageRef = useRef(fetchPage); + + useEffect(() => { + fetchPageRef.current = fetchPage; + }, [fetchPage]); + + const merge = useMemo( + () => + mergeItems || + ((prev: T[], next: T[]) => { + return [...prev, ...next]; + }), + [mergeItems], + ); + + const loadFirstPage = useCallback(async () => { + if (!enabled || isFetchingRef.current) return; + isFetchingRef.current = true; + setIsLoading(true); + setError(null); + + try { + const res = await fetchPageRef.current(null); + setItems(res.items); + setNextCursor(res.nextCursor); + setIsLast(res.isLast); + } catch (error) { + setError(error instanceof Error ? error.message : '목록을 불러오지 못했습니다.'); + } finally { + setIsLoading(false); + isFetchingRef.current = false; + } + }, [enabled]); + + const loadMore = useCallback(async () => { + if (!enabled || isFetchingRef.current || isLast || !nextCursor) return; + isFetchingRef.current = true; + setIsLoadingMore(true); + + try { + const res = await fetchPageRef.current(nextCursor); + setItems(prev => merge(prev, res.items)); + setNextCursor(res.nextCursor); + setIsLast(res.isLast); + } catch (error) { + setError(error instanceof Error ? error.message : '추가 목록을 불러오지 못했습니다.'); + } finally { + setIsLoadingMore(false); + isFetchingRef.current = false; + } + }, [enabled, isLast, nextCursor, merge]); + + useEffect(() => { + if (!enabled) return; + setItems([]); + setNextCursor(null); + setIsLast(false); + void loadFirstPage(); + }, [enabled, reloadKey, loadFirstPage]); + + useEffect(() => { + if (!enabled || !sentinelRef.current) return; + + const observer = new IntersectionObserver( + entries => { + if (entries[0].isIntersecting) { + void loadMore(); + } + }, + { root: rootRef?.current || null, rootMargin, threshold }, + ); + + observer.observe(sentinelRef.current); + + return () => observer.disconnect(); + }, [enabled, loadMore, rootMargin, threshold, rootRef]); + + return { + items, + setItems, + isLast, + isLoading, + isLoadingMore, + error, + sentinelRef, + reload: loadFirstPage, + }; +}; diff --git a/src/pages/feed/Feed.tsx b/src/pages/feed/Feed.tsx index 1c58e85d..61983d48 100644 --- a/src/pages/feed/Feed.tsx +++ b/src/pages/feed/Feed.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect } from 'react'; import NavBar from '../../components/common/NavBar'; import TabBar from '../../components/feed/TabBar'; import MyFeed from '../../components/feed/MyFeed'; @@ -10,6 +10,7 @@ import { useNavigate, useLocation } from 'react-router-dom'; import { getTotalFeeds } from '@/api/feeds/getTotalFeed'; import { getMyFeeds } from '@/api/feeds/getMyFeed'; import { useSocialLoginToken } from '@/hooks/useSocialLoginToken'; +import { useInifinieScroll } from '@/hooks/useInifinieScroll'; import { Container, SkeletonWrapper } from './Feed.styled'; import type { PostData } from '@/types/post'; @@ -30,20 +31,6 @@ const Feed = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const [totalFeedPosts, setTotalFeedPosts] = useState([]); - const [totalLoading, setTotalLoading] = useState(false); - const [totalNextCursor, setTotalNextCursor] = useState(''); - const [totalIsLast, setTotalIsLast] = useState(false); - - const [myFeedPosts, setMyFeedPosts] = useState([]); - const [myLoading, setMyLoading] = useState(false); - const [myNextCursor, setMyNextCursor] = useState(''); - const [myIsLast, setMyIsLast] = useState(false); - - const [tabLoading, setTabLoading] = useState(false); - - const [initialLoading, setInitialLoading] = useState(true); - const handleSearchButton = () => { navigate('/feed/search'); }; @@ -52,90 +39,45 @@ const Feed = () => { navigate('/notice'); }; - const loadTotalFeeds = useCallback(async (_cursor?: string) => { - try { - setTotalLoading(true); - - const response = await getTotalFeeds(_cursor ? { cursor: _cursor } : undefined); - - if (_cursor) { - setTotalFeedPosts(prev => { - const existingIds = new Set(prev.map(post => post.feedId)); - const newPosts = response.data.feedList.filter(post => !existingIds.has(post.feedId)); - return [...prev, ...newPosts]; - }); - } else { - setTotalFeedPosts(response.data.feedList); - } - - setTotalNextCursor(response.data.nextCursor); - setTotalIsLast(response.data.isLast); - } catch (error) { - console.error('전체 피드 로드 실패:', error); - } finally { - setTotalLoading(false); - } - }, []); - - const loadMyFeeds = useCallback(async (_cursor?: string) => { - try { - setMyLoading(true); - const response = await getMyFeeds(_cursor ? { cursor: _cursor } : undefined); - - if (_cursor) { - setMyFeedPosts(prev => [...prev, ...response.data.feedList]); - } else { - setMyFeedPosts(response.data.feedList); - } - - setMyNextCursor(response.data.nextCursor); - setMyIsLast(response.data.isLast); - } catch (error) { - console.error('내 피드 로드 실패:', error); - } finally { - setMyLoading(false); - } - }, []); - - const loadMoreFeeds = useCallback(() => { - if (activeTab === '피드') { - if (!totalIsLast && !totalLoading && totalNextCursor) { - loadTotalFeeds(totalNextCursor); - } - } else { - if (!myIsLast && !myLoading && myNextCursor) { - loadMyFeeds(myNextCursor); - } - } - }, [activeTab, totalIsLast, totalLoading, totalNextCursor, myIsLast, myLoading, myNextCursor]); - - useEffect(() => { - const handleScroll = () => { - const isLoading = activeTab === '피드' ? totalLoading : myLoading; - const isLastPage = activeTab === '피드' ? totalIsLast : myIsLast; - - if (isLoading || isLastPage) return; - - const scrollTop = window.pageYOffset || document.documentElement.scrollTop; - const windowHeight = window.innerHeight; - const documentHeight = document.documentElement.scrollHeight; - - if (scrollTop + windowHeight >= documentHeight - 200) { - loadMoreFeeds(); - } - }; - - window.addEventListener('scroll', handleScroll); - - return () => { - window.removeEventListener('scroll', handleScroll); - }; - }, [activeTab, totalLoading, myLoading, totalIsLast, myIsLast, loadMoreFeeds]); + const totalFeed = useInifinieScroll({ + enabled: activeTab === '피드', + reloadKey: activeTab, + fetchPage: async cursor => { + await waitForToken(); + const response = await getTotalFeeds(cursor ? { cursor } : undefined); + return { + items: response.data.feedList, + nextCursor: response.data.nextCursor || null, + isLast: response.data.isLast, + }; + }, + mergeItems: (prev, next) => { + const existingIds = new Set(prev.map(post => post.feedId)); + const newPosts = next.filter(post => !existingIds.has(post.feedId)); + return [...prev, ...newPosts]; + }, + }); + + const myFeed = useInifinieScroll({ + enabled: activeTab === '내 피드', + reloadKey: activeTab, + fetchPage: async cursor => { + await waitForToken(); + const response = await getMyFeeds(cursor ? { cursor } : undefined); + return { + items: response.data.feedList, + nextCursor: response.data.nextCursor || null, + isLast: response.data.isLast, + }; + }, + }); useEffect(() => { window.scrollTo(0, 0); }, [activeTab]); + const currentFeed = activeTab === '피드' ? totalFeed : myFeed; + useEffect(() => { const loadFeedsWithToken = async () => { await waitForToken(); @@ -183,16 +125,23 @@ const Feed = () => { <> ) : ( <> - + )} + {!currentFeed.isLast &&
} + {currentFeed.isLoadingMore && } )} diff --git a/src/pages/feed/FeedDetailPage.tsx b/src/pages/feed/FeedDetailPage.tsx index f514144d..83169c01 100644 --- a/src/pages/feed/FeedDetailPage.tsx +++ b/src/pages/feed/FeedDetailPage.tsx @@ -1,5 +1,5 @@ import { useNavigate, useParams } from 'react-router-dom'; -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect } from 'react'; import TitleHeader from '@/components/common/TitleHeader'; import FeedDetailPost from '@/components/feed/FeedDetailPost'; import leftArrow from '../../assets/common/leftArrow.svg'; @@ -9,7 +9,6 @@ import MessageInput from '@/components/today-words/MessageInput'; import { usePopupActions } from '@/hooks/usePopupActions'; import { useReplyActions } from '@/hooks/useReplyActions'; import { getFeedDetail, type FeedDetailData } from '@/api/feeds/getFeedDetail'; -import { getComments, type CommentData } from '@/api/comments/getComments'; import { deleteFeedPost } from '@/api/feeds/deleteFeedPost'; import Skeleton, { FeedPostSkeleton } from '@/shared/ui/Skeleton'; import { Wrapper, SkeletonWrapper, CommentSkeletonItem } from './FeedDetailPage.styled'; @@ -19,7 +18,7 @@ const FeedDetailPage = () => { const navigate = useNavigate(); const { feedId } = useParams<{ feedId: string }>(); const [feedData, setFeedData] = useState(null); - const [commentList, setCommentList] = useState([]); + const [replyReloadKey, setReplyReloadKey] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -27,27 +26,6 @@ const FeedDetailPage = () => { const { isReplying, replyContent, setReplyContent, submitComment, cancelReply } = useReplyActions(); const { nickname } = useReplyStore(); - const reloadComments = useCallback(async () => { - if (!feedId) return; - - try { - const commentsResponse = await getComments(Number(feedId), { postType: 'FEED' }); - setCommentList(commentsResponse.data.commentList); - - if (feedData) { - setFeedData(prev => - prev - ? { - ...prev, - commentCount: commentsResponse.data.commentList.length, - } - : null, - ); - } - } catch (err) { - console.error('댓글 목록 다시 로드 실패:', err); - } - }, [feedId, feedData]); useEffect(() => { return () => { @@ -56,7 +34,7 @@ const FeedDetailPage = () => { }, [cancelReply]); useEffect(() => { - const loadFeedDetailAndComments = async () => { + const loadFeedDetail = async () => { if (!feedId) { setError('피드 ID가 없습니다.'); setLoading(false); @@ -66,6 +44,7 @@ const FeedDetailPage = () => { try { setLoading(true); + const feedResponse = await getFeedDetail(Number(feedId)); const minLoadingTime = new Promise(resolve => setTimeout(resolve, 500)); const [feedResponse, commentsResponse] = await Promise.all([ getFeedDetail(Number(feedId)), @@ -74,24 +53,23 @@ const FeedDetailPage = () => { await minLoadingTime; setFeedData(feedResponse.data); - setCommentList(commentsResponse.data.commentList); setError(null); } catch (err) { - console.error('피드 상세 정보 또는 댓글 로드 실패:', err); + console.error('피드 상세 정보 로드 실패:', err); setError('피드 정보를 불러오는데 실패했습니다.'); } finally { setLoading(false); } }; - loadFeedDetailAndComments(); + loadFeedDetail(); }, [feedId]); const handleCommentSubmit = async () => { await submitComment({ postId: Number(feedId), postType: 'FEED', - onSuccess: reloadComments, + onSuccess: () => setReplyReloadKey(prev => prev + 1), }); }; @@ -212,7 +190,7 @@ const FeedDetailPage = () => { onRightClick={handleMoreClick} /> - + { const { type, userId } = useParams<{ type: UserProfileType; userId?: string }>(); const title = type === 'followerlist' ? '띱 목록' : '내 띱 목록'; - const [userList, setUserList] = useState([]); const [totalCount, setTotalCount] = useState(0); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [retryCount, setRetryCount] = useState(0); - const [nextCursor, setNextCursor] = useState(''); - const [isLast, setIsLast] = useState(false); const handleBackClick = () => { navigate(-1); @@ -67,64 +62,32 @@ const FollowerListPage = () => { setError('API 응답이 없습니다.'); return; } - - if (cursor) { - setUserList(prev => [...prev, ...userData]); - } else { - setUserList(userData); - } - - setNextCursor(response.data.nextCursor); - setIsLast(response.data.isLast); - if (type === 'followerlist') { - const total = (response.data as { totalFollowerCount?: number }).totalFollowerCount; - if (typeof total === 'number') setTotalCount(total); - } else { - const total = (response.data as { totalFollowingCount?: number }).totalFollowingCount; - if (typeof total === 'number') setTotalCount(total); - } - setRetryCount(0); - } catch (error) { - console.error('사용자 목록 로드 실패:', error); - setError('사용자 목록을 불러오는데 실패했습니다.'); - setRetryCount(prev => prev + 1); - } finally { - setLoading(false); - } - }, - [type, userId], - ); - - useEffect(() => { - const handleScroll = () => { - if (loading || isLast || error || retryCount >= 3 || !nextCursor) { - return; + const response = await getFollowerList(userId, { size: 10, cursor }); + const total = response.data.totalFollowerCount; + if (typeof total === 'number') setTotalCount(total); + return { + items: response.data.followers || [], + nextCursor: response.data.nextCursor || null, + isLast: response.data.isLast, + }; } - const scrollTop = window.pageYOffset || document.documentElement.scrollTop; - const windowHeight = window.innerHeight; - const documentHeight = document.documentElement.scrollHeight; - - if (scrollTop + windowHeight >= documentHeight - 100) { - loadUserList(nextCursor); - } - }; - - window.addEventListener('scroll', handleScroll); - return () => window.removeEventListener('scroll', handleScroll); - }, [isLast, error, retryCount, nextCursor, loadUserList]); - - useEffect(() => { - loadUserList(); - }, [loadUserList]); + const response = await getFollowingList({ size: 10, cursor }); + const total = response.data.totalFollowingCount; + if (typeof total === 'number') setTotalCount(total); + return { + items: response.data.followings || [], + nextCursor: response.data.nextCursor || null, + isLast: response.data.isLast, + }; + }, + rootMargin: '100px 0px', + threshold: 0.1, + }); useEffect(() => { - const doc = document.documentElement; - const needsMore = doc.scrollHeight <= window.innerHeight + 100; - if (!loading && !isLast && !!nextCursor && needsMore) { - loadUserList(nextCursor); - } - }, [userList, loading, isLast, nextCursor, loadUserList]); + window.scrollTo(0, 0); + }, [type, userId]); return ( @@ -138,7 +101,7 @@ const FollowerListPage = () => { ) : ( - {userList.map((user, index) => ( + {userList.items.map((user, index) => ( { userId={user.userId} type={type as UserProfileType} isFollowing={user.isFollowing} - isLast={index === userList.length - 1} + isLast={index === userList.items.length - 1} isMyself={user.isMyself} /> ))} - {loading && userList.length > 0 && ( + {!userList.isLast &&
} + {userList.isLoadingMore && (
diff --git a/src/pages/groupSearch/GroupSearch.tsx b/src/pages/groupSearch/GroupSearch.tsx index d2074465..c96ba157 100644 --- a/src/pages/groupSearch/GroupSearch.tsx +++ b/src/pages/groupSearch/GroupSearch.tsx @@ -3,14 +3,16 @@ import { Modal, Overlay } from '@/components/group/Modal.styles'; import leftArrow from '../../assets/common/leftArrow.svg'; import SearchBar from '@/components/search/SearchBar'; import rightChevron from '../../assets/common/right-Chevron.svg'; -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import RecentSearchTabs from '@/components/search/RecentSearchTabs'; import GroupSearchResult from '@/components/search/GroupSearchResult'; +import LoadingSpinner from '@/components/common/LoadingSpinner'; import { getRecentSearch, type RecentSearchData } from '@/api/recentsearch/getRecentSearch'; import { deleteRecentSearch } from '@/api/recentsearch/deleteRecentSearch'; -import { getSearchRooms, type SearchRoomItem } from '@/api/rooms/getSearchRooms'; +import { getSearchRooms } from '@/api/rooms/getSearchRooms'; import { useNavigate, useLocation } from 'react-router-dom'; -import { AllRoomsButton } from './GroupSearch.styled'; +import { AllRoomsButton, LoadingMessage } from './GroupSearch.styled'; +import { useInifinieScroll } from '@/hooks/useInifinieScroll'; import { GroupCardSkeleton, RecentSearchTabsSkeleton } from '@/shared/ui/Skeleton'; import { Content } from '@/components/search/GroupSearchResult.styled'; @@ -24,29 +26,21 @@ const GroupSearch = () => { const [searchTerm, setSearchTerm] = useState(''); const [searchStatus, setSearchStatus] = useState('idle'); - const [rooms, setRooms] = useState([]); - const [nextCursor, setNextCursor] = useState(null); - const [isLast, setIsLast] = useState(true); - - const [isLoading, setIsLoading] = useState(false); - const [isLoadingMore, setIsLoadingMore] = useState(false); - const [error, setError] = useState(null); - const [selectedFilter, setSelectedFilter] = useState('마감임박순'); const toSortKey = useCallback( (f: string): SortKey => (f === '인기순' ? 'memberCount' : 'deadline'), [], ); const [category, setCategory] = useState(''); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); const [recentSearches, setRecentSearches] = useState([]); + const [isLoadingRecentSearches, setIsLoadingRecentSearches] = useState(true); const [searchTimeoutId, setSearchTimeoutId] = useState(null); const [showTabs, setShowTabs] = useState(false); - const observerRef = useRef(null); - useEffect(() => { fetchRecentSearches(); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -138,10 +132,7 @@ const GroupSearch = () => { const trimmed = value.trim(); if (!trimmed) { setSearchStatus('idle'); - setRooms([]); - setError(null); - setNextCursor(null); - setIsLast(true); + setDebouncedSearchTerm(''); setShowTabs(false); setSearchTimeoutId(null); return; @@ -183,6 +174,7 @@ const GroupSearch = () => { setSearchTimeoutId(null); } setSearchTerm(''); + setDebouncedSearchTerm(''); setSearchStatus('searched'); setShowTabs(true); setCategory(''); @@ -229,13 +221,10 @@ const GroupSearch = () => { }, [selectedFilter, category]); useEffect(() => { - const term = searchTerm.trim(); - if (!term || searchStatus !== 'searching') return; - - if (searchTimeoutId) { - clearTimeout(searchTimeoutId); - setSearchTimeoutId(null); + if (searchStatus === 'searched') { + setDebouncedSearchTerm(searchTerm.trim()); } + }, [searchStatus, searchTerm]); const id = setTimeout(() => { const currentCategory = categoryRef.current; @@ -252,47 +241,33 @@ const GroupSearch = () => { try { setIsLoadingMore(true); const isFinalized = searchStatus === 'searched'; + const isAllCategory = !queryTerm && category === ''; + if (searchStatus === 'searching' && !queryTerm) { + return { items: [], nextCursor: null, isLast: true }; + } + const res = await getSearchRooms( - trimmedTerm, + queryTerm, toSortKey(selectedFilter), - nextCursor, + cursor ?? undefined, isFinalized, category, isAllCategory, ); - if (res.isSuccess) { - const { roomList, nextCursor: nc, isLast: last } = res.data; - setRooms(prev => [...prev, ...roomList]); - setNextCursor(nc); - setIsLast(last); - } else { - setIsLast(true); + if (!res.isSuccess) { + throw new Error(res.message || '검색 실패'); } - } catch { - setIsLast(true); - } finally { - setIsLoadingMore(false); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchTerm, nextCursor, isLast, isLoadingMore, selectedFilter, searchStatus, category]); - - const lastRoomElementCallback = useCallback( - (node: HTMLDivElement | null) => { - if (isLoadingMore || isLast) return; - if (observerRef.current) observerRef.current.disconnect(); - - observerRef.current = new IntersectionObserver(entries => { - if (entries[0].isIntersecting && !isLoadingMore && !isLast) { - loadMore(); - } - }); - - if (node) observerRef.current.observe(node); + return { + items: res.data.roomList, + nextCursor: res.data.nextCursor, + isLast: res.data.isLast, + }; }, - [isLoadingMore, isLast, loadMore], - ); + rootMargin: '100px 0px', + threshold: 0.1, + }); const handleBackButton = () => { if (searchTimeoutId) { @@ -304,11 +279,8 @@ const GroupSearch = () => { if (!isIdleView) { setSearchTerm(''); + setDebouncedSearchTerm(''); setSearchStatus('idle'); - setRooms([]); - setNextCursor(null); - setIsLast(true); - setError(null); setShowTabs(false); return; } @@ -323,7 +295,6 @@ const GroupSearch = () => { useEffect(() => { return () => { if (searchTimeoutId) clearTimeout(searchTimeoutId); - if (observerRef.current) observerRef.current.disconnect(); }; }, [searchTimeoutId]); @@ -356,12 +327,12 @@ const GroupSearch = () => { { const [roomCompleted, setRoomCompleted] = useState(false); - const [myRecords, setMyRecords] = useState([]); - const [groupRecords, setGroupRecords] = useState([]); const [totalPages, setTotalPages] = useState(0); const [currentUserPage, setCurrentUserPage] = useState(0); + const scrollRootRef = useRef(null); + + const recordsList = useInifinieScroll({ + enabled: !!roomId, + reloadKey: `${roomId}-${activeTab}-${selectedSort}-${activeFilter}-${selectedPageRange?.start ?? ''}-${selectedPageRange?.end ?? ''}`, + rootRef: scrollRootRef, + fetchPage: async cursor => { + if (!roomId) { + return { items: [], nextCursor: null, isLast: true }; + } const loadMemoryPosts = useCallback(async () => { if (!roomId) { @@ -108,8 +118,9 @@ const Memory = () => { try { const params: GetMemoryPostsParams = { - roomId: parseInt(roomId), + roomId: parseInt(roomId, 10), type: activeTab === 'group' ? 'group' : 'mine', + cursor, }; if (activeTab === 'group') { @@ -130,10 +141,8 @@ const Memory = () => { if (response.isSuccess) { const convertedRecords = response.data.postList.map(convertPostToRecord); - if (activeTab === 'group') { - setGroupRecords(convertedRecords); - } else { - setMyRecords(convertedRecords); + if (!response.isSuccess) { + throw new Error(response.message || '기록을 불러오는 중 오류가 발생했습니다.'); } if (response.data.totalPages !== undefined) { @@ -142,17 +151,21 @@ const Memory = () => { if (response.data.currentUserPage !== undefined) { setCurrentUserPage(response.data.currentUserPage); } - } else { - setError(response.message); - } - } catch (error) { - if (error && typeof error === 'object' && 'response' in error) { - const axiosError = error as { response?: { data?: { code?: number } } }; - if (axiosError.response?.data?.code === 40002) { - setError('독서 진행률이 80% 이상이어야 총평을 볼 수 있습니다.'); - setActiveFilter(null); - return; + + return { + items: response.data.postList.map(convertPostToRecord), + nextCursor: response.data.nextCursor, + isLast: response.data.isLast, + }; + } catch (error) { + if (error && typeof error === 'object' && 'response' in error) { + const axiosError = error as { response?: { data?: { code?: number } } }; + if (axiosError.response?.data?.code === 40002) { + setActiveFilter(null); + throw new Error('독서 진행률이 80% 이상이어야 총평을 볼 수 있습니다.'); + } } + throw new Error('기록을 불러오는 중 오류가 발생했습니다.'); } setError('기록을 불러오는 중 오류가 발생했습니다.'); @@ -179,10 +192,6 @@ const Memory = () => { checkRoomStatus(); }, [roomId]); - useEffect(() => { - loadMemoryPosts(); - }, [loadMemoryPosts]); - useEffect(() => { type MemoryLocationState = { page?: number; @@ -208,20 +217,13 @@ const Memory = () => { if (location.state?.newRecord) { const newRecord = location.state.newRecord as Record; setShowUploadProgress(true); - - if (activeTab === 'group') { - setGroupRecords(prev => [newRecord, ...prev]); - } else { - setMyRecords(prev => [newRecord, ...prev]); - } + setRecordItems(prev => [newRecord, ...prev]); navigate(location.pathname, { replace: true }); } - }, [location.state, activeTab, navigate, location.pathname]); + }, [location.state, navigate, location.pathname, setRecordItems]); - const currentRecords = useMemo(() => { - return activeTab === 'group' ? groupRecords : myRecords; - }, [activeTab, groupRecords, myRecords]); + const currentRecords = useMemo(() => recordsList.items, [recordsList.items]); const sortedRecords = useMemo(() => { return currentRecords; @@ -292,15 +294,15 @@ const Memory = () => { const readingProgress = totalPages > 0 ? Math.round((currentUserPage / totalPages) * 100) : 0; - if (error) { + if (recordsList.error) { return (
- 오류가 발생했습니다: {error} -
@@ -313,7 +315,6 @@ const Memory = () => { - {loading ? ( diff --git a/src/pages/mypage/SavePage.tsx b/src/pages/mypage/SavePage.tsx index 814de032..a953466b 100644 --- a/src/pages/mypage/SavePage.tsx +++ b/src/pages/mypage/SavePage.tsx @@ -1,5 +1,5 @@ import { useNavigate } from 'react-router-dom'; -import { useState, useEffect, useRef, useCallback } from 'react'; +import { useEffect, useState } from 'react'; import TitleHeader from '../../components/common/TitleHeader'; import TabBar from '@/components/feed/TabBar'; import FeedPost from '@/components/feed/FeedPost'; @@ -9,6 +9,7 @@ import activeSave from '../../assets/feed/activeSave.svg'; import { getSavedBooksInMy, type SavedBookInMy } from '@/api/books/getSavedBooksInMy'; import { getSavedFeedsInMy, type SavedFeedInMy } from '@/api/feeds/getSavedFeedsInMy'; import { postSaveBook } from '@/api/books/postSaveBook'; +import { useInifinieScroll } from '@/hooks/useInifinieScroll'; import LoadingSpinner from '@/components/common/LoadingSpinner'; import Skeleton, { FeedPostSkeleton } from '@/shared/ui/Skeleton'; import { @@ -34,20 +35,35 @@ const SavePage = () => { const navigate = useNavigate(); const [activeTab, setActiveTab] = useState(tabs[0]); - const [savedFeeds, setSavedFeeds] = useState([]); - const [feedNextCursor, setFeedNextCursor] = useState(null); - const [feedIsLast, setFeedIsLast] = useState(false); - const [feedLoading, setFeedLoading] = useState(false); - - const [savedBooks, setSavedBooks] = useState([]); - const [bookNextCursor, setBookNextCursor] = useState(null); - const [bookIsLast, setBookIsLast] = useState(false); - const [bookLoading, setBookLoading] = useState(false); - - const [initialLoading, setInitialLoading] = useState(true); - - const feedObserverRef = useRef(null); - const bookObserverRef = useRef(null); + const savedFeeds = useInifinieScroll({ + enabled: activeTab === '피드', + reloadKey: activeTab, + fetchPage: async cursor => { + const response = await getSavedFeedsInMy(cursor); + return { + items: response.data.feedList, + nextCursor: response.data.nextCursor || null, + isLast: response.data.isLast, + }; + }, + rootMargin: '100px 0px', + threshold: 0.1, + }); + + const savedBooks = useInifinieScroll({ + enabled: activeTab === '책', + reloadKey: activeTab, + fetchPage: async cursor => { + const response = await getSavedBooksInMy(cursor); + return { + items: response.data.bookList, + nextCursor: response.data.nextCursor || null, + isLast: response.data.isLast, + }; + }, + rootMargin: '100px 0px', + threshold: 0.1, + }); const handleBack = () => { navigate('/mypage'); @@ -173,35 +189,39 @@ const SavePage = () => { }, [activeTab]); const handleSaveToggle = async (isbn: string) => { - try { - const currentBook = savedBooks.find(book => book.isbn === isbn); - if (!currentBook) return; - - const newSaveState = !currentBook.isSaved; - await postSaveBook(isbn, newSaveState); + const currentBook = savedBooks.items.find(book => book.isbn === isbn); + if (!currentBook) return; + + const nextSaved = !currentBook.isSaved; + const previousBooks = savedBooks.items; + + if (!nextSaved) { + savedBooks.setItems(prev => prev.filter(book => book.isbn !== isbn)); + } else { + savedBooks.setItems(prev => + prev.map(book => (book.isbn === isbn ? { ...book, isSaved: nextSaved } : book)), + ); + } - if (!newSaveState) { - await loadSavedBooks(); - } else { - setSavedBooks(prev => - prev.map(book => (book.isbn === isbn ? { ...book, isSaved: newSaveState } : book)), - ); + try { + const response = await postSaveBook(isbn, nextSaved); + if (!response.isSuccess) { + savedBooks.setItems(previousBooks); } - } catch (error) { - console.error('저장 토글 실패:', error); + } catch { + savedBooks.setItems(previousBooks); } }; - const handleFeedSaveToggle = async (feedId: number, newSaveState: boolean) => { - try { - if (!newSaveState) { - setSavedFeeds(prev => prev.filter(feed => feed.feedId !== feedId)); - } - } catch (error) { - console.error('피드 저장 상태 변경 실패:', error); + const handleFeedSaveToggle = (feedId: number, newSaveState: boolean) => { + if (!newSaveState) { + savedFeeds.setItems(prev => prev.filter(feed => feed.feedId !== feedId)); } }; + const currentList = activeTab === '피드' ? savedFeeds : savedBooks; + const showInitialLoading = currentList.isLoading && currentList.items.length === 0; + return ( { ) ) : activeTab === '피드' ? ( <> - {savedFeeds.length > 0 ? ( + {savedFeeds.items.length > 0 ? ( - {savedFeeds.map((feed, index) => ( + {savedFeeds.items.map((feed, index) => ( ))} - {/* 무한스크롤을 위한 observer 요소 */} - {!feedIsLast && ( -
- {feedLoading && } -
- )} + {!savedFeeds.isLast &&
} + {savedFeeds.isLoadingMore && } ) : ( @@ -261,39 +277,27 @@ const SavePage = () => { )} - ) : savedBooks.length > 0 ? ( - <> - - {savedBooks.map((book, index) => ( - - - - - {book.bookTitle} - - {book.authorName} 저 · {book.publisher} - - - - handleSaveToggle(book.isbn)}> - {book.isSaved - - - ))} - {/* 무한스크롤을 위한 observer 요소 */} - {!bookIsLast && ( -
- {bookLoading && } -
- )} -
- + ) : savedBooks.items.length > 0 ? ( + + {savedBooks.items.map(book => ( + + + + + {book.bookTitle} + + {book.authorName} 저 · {book.publisher} + + + + handleSaveToggle(book.isbn)}> + {book.isSaved + + + ))} + {!savedBooks.isLast &&
} + {savedBooks.isLoadingMore && } + ) : (
아직 저장한 책이 없어요
diff --git a/src/pages/notice/Notice.tsx b/src/pages/notice/Notice.tsx index d6c74de7..a36321ef 100644 --- a/src/pages/notice/Notice.tsx +++ b/src/pages/notice/Notice.tsx @@ -1,9 +1,10 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import TitleHeader from '@/components/common/TitleHeader'; import leftArrow from '../../assets/common/leftArrow.svg'; import { getNotifications, type NotificationItem } from '@/api/notifications/getNotifications'; import { postNotificationsCheck } from '@/api/notifications/postNotificationsCheck'; +import { useInifinieScroll } from '@/hooks/useInifinieScroll'; import { Wrapper, TabContainer, @@ -22,12 +23,6 @@ import { const Notice = () => { const [selected, setSelected] = useState(''); - const [notifications, setNotifications] = useState([]); - const [nextCursor, setNextCursor] = useState(null); - const [isLast, setIsLast] = useState(true); - const [isLoading, setIsLoading] = useState(false); - const isLoadingRef = useRef(false); - const sentinelRef = useRef(null); const navigate = useNavigate(); const handleBackButton = () => { @@ -38,60 +33,23 @@ const Notice = () => { setSelected(prev => (prev === tab ? '' : tab)); }; - const loadNotifications = useCallback( - async (cursor?: string | null) => { - try { - if (isLoadingRef.current) return; - isLoadingRef.current = true; - setIsLoading(true); - const params: { cursor?: string | null; type?: 'feed' | 'room' } = { cursor }; - if (selected === '피드') params.type = 'feed'; - if (selected === '모임') params.type = 'room'; - - const res = await getNotifications(params); - if (res.isSuccess) { - setNotifications(prev => - cursor ? [...prev, ...res.data.notifications] : res.data.notifications, - ); - setNextCursor(res.data.nextCursor || null); - setIsLast(res.data.isLast); - } - } finally { - setIsLoading(false); - isLoadingRef.current = false; - } + const notificationList = useInifinieScroll({ + enabled: true, + reloadKey: selected, + fetchPage: async cursor => { + const params: { cursor?: string | null; type?: 'feed' | 'room' } = { cursor }; + if (selected === '피드') params.type = 'feed'; + if (selected === '모임') params.type = 'room'; + const res = await getNotifications(params); + return { + items: res.data.notifications, + nextCursor: res.data.nextCursor || null, + isLast: res.data.isLast, + }; }, - [selected], - ); - - useEffect(() => { - setNotifications([]); - setNextCursor(null); - setIsLast(false); - void loadNotifications(null); - }, [selected, loadNotifications]); - - useEffect(() => { - if (!sentinelRef.current) return; - const el = sentinelRef.current; - const observer = new IntersectionObserver( - entries => { - const entry = entries[0]; - if (entry.isIntersecting && !isLoading && !isLast && nextCursor !== null) { - void loadNotifications(nextCursor); - } - }, - { root: null, rootMargin: '0px', threshold: 0.1 }, - ); - - observer.observe(el); - return () => { - observer.unobserve(el); - observer.disconnect(); - }; - }, [isLoading, isLast, nextCursor, loadNotifications]); - - const filteredNotifications = notifications; + rootMargin: '100px 0px', + threshold: 0.1, + }); const tabs = ['피드', '모임']; @@ -178,10 +136,10 @@ const Notice = () => { - {filteredNotifications.length === 0 ? ( + {notificationList.items.length === 0 && !notificationList.isLoading ? ( 새로운 알림이 없어요 ) : ( - filteredNotifications.map((notif, idx) => ( + notificationList.items.map((notif, idx) => ( { )} - + {!notificationList.isLast && } ); }; diff --git a/src/pages/searchBook/SearchBook.tsx b/src/pages/searchBook/SearchBook.tsx index 2546d735..804e6b84 100644 --- a/src/pages/searchBook/SearchBook.tsx +++ b/src/pages/searchBook/SearchBook.tsx @@ -29,7 +29,7 @@ import saveIcon from '../../assets/common/SaveIcon.svg'; import filledSaveIcon from '../../assets/common/filledSaveIcon.svg'; import rightChevron from '../../assets/common/right-Chevron.svg'; import plusIcon from '../../assets/common/plus.svg'; -import { useState, useEffect, useRef, useCallback } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { IntroModal } from '@/components/search/IntroModal'; import { getBookDetail, type BookDetail } from '@/api/books/getBookDetail'; import { getRecruitingRooms, type RecruitingRoomsData } from '@/api/books/getRecruitingRooms'; @@ -40,6 +40,7 @@ import { getFeedsByIsbn, type FeedItem, type FeedSort } from '@/api/feeds/getFee import { usePopupStore } from '@/stores/popupStore'; import { FeedPostSkeleton, BookDetailSkeleton } from '@/shared/ui/Skeleton'; import { usePreventDoubleClick } from '@/hooks/usePreventDoubleClick'; +import { useInifinieScroll } from '@/hooks/useInifinieScroll'; const FILTER = ['최신순', '인기순'] as const; const toFeedSort = (f: (typeof FILTER)[number]): FeedSort => (f === '최신순' ? 'latest' : 'like'); @@ -60,13 +61,6 @@ const SearchBook = () => { const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const [feeds, setFeeds] = useState([]); - const [nextCursor, setNextCursor] = useState(null); - const [isLast, setIsLast] = useState(true); - const [isLoadingFeeds, setIsLoadingFeeds] = useState(false); - const [isLoadingMore, setIsLoadingMore] = useState(false); - - const observerRef = useRef(null); const openPopup = usePopupStore(state => state.openPopup); useEffect(() => { @@ -168,8 +162,9 @@ const SearchBook = () => { }); if (node) observerRef.current.observe(node); }, - [isLoadingMore, isLast, loadMore], - ); + rootMargin: '100px 0px', + threshold: 0.1, + }); const handleBackButton = () => navigate(-1); const handleIntroClick = () => setShowIntroModal(true); @@ -315,11 +310,8 @@ const SearchBook = () => { ) : feeds.length > 0 ? ( - {feeds.map((post, idx) => ( -
lastFeedElementCallback(el) : undefined} - > + {feeds.items.map(post => ( +
{ />
))} + {!feeds.isLast &&
} + {feeds.isLoadingMore && 불러오는 중...} ) : (