diff --git a/src/components/common/Post/PostBody.tsx b/src/components/common/Post/PostBody.tsx index 7d224c1..77553dc 100644 --- a/src/components/common/Post/PostBody.tsx +++ b/src/components/common/Post/PostBody.tsx @@ -1,4 +1,5 @@ import { useRef, useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import BookInfoCard from '../../feed/BookInfoCard'; import type { PostBodyProps } from '@/types/post'; import lookmore from '../../../assets/feed/lookmore.svg'; @@ -12,13 +13,13 @@ const PostBody = ({ feedId, contentUrls = [], }: PostBodyProps) => { + const navigate = useNavigate(); const hasImage = contentUrls.length > 0; const contentRef = useRef(null); const [isTruncated, setIsTruncated] = useState(false); const handlePostClick = (feedId: number) => { - // 새 탭에서 피드 상세 페이지 열기 - window.open(`/feed/${feedId}`, '_blank'); + navigate(`/feed/${feedId}`); }; useEffect(() => { diff --git a/src/components/common/Post/PostFooter.tsx b/src/components/common/Post/PostFooter.tsx index 285adba..ec18e5f 100644 --- a/src/components/common/Post/PostFooter.tsx +++ b/src/components/common/Post/PostFooter.tsx @@ -9,6 +9,7 @@ import { postSaveFeed } from '@/api/feeds/postSave'; import { postFeedLike } from '@/api/feeds/postFeedLike'; import { usePreventDoubleClick } from '@/hooks/usePreventDoubleClick'; import { Container } from './PostFooter.styled'; +import { useNavigate } from 'react-router-dom'; interface PostFooterProps { likeCount: number; @@ -33,6 +34,7 @@ const PostFooter = ({ isDetail = false, onSaveToggle, }: PostFooterProps) => { + const navigate = useNavigate(); const [liked, setLiked] = useState(isLiked); const [likeCount, setLikeCount] = useState(initialLikeCount); const [saved, setSaved] = useState(isSaved); @@ -106,14 +108,18 @@ const PostFooter = ({ const handleComment = () => { if (isDetail) return; - window.open(`/feed/${feedId}`, '_blank'); + navigate(`/feed/${feedId}`); }; return (
- +
{likeCount}
diff --git a/src/hooks/useFeedCache.ts b/src/hooks/useFeedCache.ts new file mode 100644 index 0000000..b96a39d --- /dev/null +++ b/src/hooks/useFeedCache.ts @@ -0,0 +1,87 @@ +import { useState, useEffect, useRef } from 'react'; +import type { PostData } from '@/types/post'; + +const FEED_CACHE_KEY = 'feed_page_cache'; +const FEED_CACHE_TTL = 10 * 60 * 1000; // 10분 + +export interface FeedCache { + activeTab: string; + totalFeedPosts: PostData[]; + myFeedPosts: PostData[]; + totalNextCursor: string; + myNextCursor: string; + totalIsLast: boolean; + myIsLast: boolean; + scrollY: number; + timestamp: number; +} + +type FeedCachePayload = Omit; + +function readCache(): FeedCache | null { + try { + const raw = sessionStorage.getItem(FEED_CACHE_KEY); + if (!raw) return null; + const cache = JSON.parse(raw) as FeedCache; + if (Date.now() - cache.timestamp < FEED_CACHE_TTL) return cache; + } catch { + // ignore + } + return null; +} + +export function writeFeedCache(payload: FeedCachePayload): void { + const cache: FeedCache = { + ...payload, + scrollY: window.scrollY, + timestamp: Date.now(), + }; + try { + sessionStorage.setItem(FEED_CACHE_KEY, JSON.stringify(cache)); + } catch { + // ignore (storage quota exceeded or private browsing) + } +} + +export function useFeedCache(options?: { disableRestore?: boolean }) { + const [initialCache] = useState(readCache); + const disableRestoreRef = useRef(options?.disableRestore ?? false); + + useEffect(() => { + if (disableRestoreRef.current || !initialCache) return; + const y = initialCache.scrollY; + requestAnimationFrame(() => { + requestAnimationFrame(() => { + window.scrollTo(0, y); + }); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + let timeout: ReturnType; + + const handleScroll = () => { + clearTimeout(timeout); + timeout = setTimeout(() => { + try { + const raw = sessionStorage.getItem(FEED_CACHE_KEY); + if (!raw) return; + const cache = JSON.parse(raw) as FeedCache; + cache.scrollY = window.scrollY; + sessionStorage.setItem(FEED_CACHE_KEY, JSON.stringify(cache)); + } catch { + // ignore + } + }, 100); + }; + + window.addEventListener('scroll', handleScroll, { passive: true }); + return () => { + window.removeEventListener('scroll', handleScroll); + clearTimeout(timeout); + }; + }, []); + + return { initialCache }; +} diff --git a/src/hooks/useInifinieScroll.ts b/src/hooks/useInifinieScroll.ts index c8033b5..8093efd 100644 --- a/src/hooks/useInifinieScroll.ts +++ b/src/hooks/useInifinieScroll.ts @@ -15,6 +15,9 @@ interface UseInifinieScrollOptions { rootRef?: RefObject; rootMargin?: string; threshold?: number; + initialItems?: T[]; + initialCursor?: string | null; + initialIsLast?: boolean; } export const useInifinieScroll = ({ @@ -25,6 +28,9 @@ export const useInifinieScroll = ({ rootRef, rootMargin = '200px 0px', threshold = 0, + initialItems, + initialCursor, + initialIsLast, }: UseInifinieScrollOptions) => { const [items, setItems] = useState([]); const [nextCursor, setNextCursor] = useState(null); @@ -36,6 +42,7 @@ export const useInifinieScroll = ({ const sentinelRef = useRef(null); const isFetchingRef = useRef(false); const fetchPageRef = useRef(fetchPage); + const initializedRef = useRef(false); useEffect(() => { fetchPageRef.current = fetchPage; @@ -89,11 +96,21 @@ export const useInifinieScroll = ({ useEffect(() => { if (!enabled) return; + + if (!initializedRef.current && initialItems && initialItems.length > 0) { + initializedRef.current = true; + setItems(initialItems); + setNextCursor(initialCursor ?? null); + setIsLast(initialIsLast ?? false); + return; + } + + initializedRef.current = true; setItems([]); setNextCursor(null); setIsLast(false); void loadFirstPage(); - }, [enabled, reloadKey, loadFirstPage]); + }, [enabled, reloadKey, loadFirstPage]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (!enabled || !sentinelRef.current) return; @@ -115,6 +132,7 @@ export const useInifinieScroll = ({ return { items, setItems, + nextCursor, isLast, isLoading, isLoadingMore, diff --git a/src/pages/feed/Feed.tsx b/src/pages/feed/Feed.tsx index 61983d4..005a009 100644 --- a/src/pages/feed/Feed.tsx +++ b/src/pages/feed/Feed.tsx @@ -1,15 +1,17 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import NavBar from '../../components/common/NavBar'; import TabBar from '../../components/feed/TabBar'; import MyFeed from '../../components/feed/MyFeed'; import TotalFeed from '../../components/feed/TotalFeed'; import MainHeader from '@/components/common/MainHeader'; import { FeedPostSkeleton, OtherFeedSkeleton } from '@/shared/ui/Skeleton'; +import LoadingSpinner from '../../components/common/LoadingSpinner'; import writefab from '../../assets/common/writefab.svg'; 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 { useFeedCache, writeFeedCache } from '@/hooks/useFeedCache'; import { useInifinieScroll } from '@/hooks/useInifinieScroll'; import { Container, SkeletonWrapper } from './Feed.styled'; import type { PostData } from '@/types/post'; @@ -19,25 +21,22 @@ const tabs = ['피드', '내 피드']; const Feed = () => { const navigate = useNavigate(); const location = useLocation(); - const initialTabFromState = (location.state as { initialTab?: string } | null)?.initialTab; - const [activeTab, setActiveTab] = useState(initialTabFromState ?? tabs[0]); - const { waitForToken } = useSocialLoginToken(); + const initialTabFromState = (location.state as { initialTab?: string } | null)?.initialTab; + const { initialCache } = useFeedCache({ disableRestore: !!initialTabFromState }); + const isRestoringFromCache = initialCache !== null && !initialTabFromState; + useEffect(() => { - if (initialTabFromState) { - navigate('.', { replace: true }); - } + if (initialTabFromState) navigate('.', { replace: true }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const handleSearchButton = () => { - navigate('/feed/search'); - }; + const [activeTab, setActiveTab] = useState( + initialTabFromState ?? (isRestoringFromCache ? initialCache!.activeTab : tabs[0]), + ); - const handleNoticeButton = () => { - navigate('/notice'); - }; + const skipScrollResetRef = useRef(isRestoringFromCache); const totalFeed = useInifinieScroll({ enabled: activeTab === '피드', @@ -56,6 +55,9 @@ const Feed = () => { const newPosts = next.filter(post => !existingIds.has(post.feedId)); return [...prev, ...newPosts]; }, + initialItems: isRestoringFromCache ? initialCache!.totalFeedPosts : undefined, + initialCursor: isRestoringFromCache ? initialCache!.totalNextCursor || null : undefined, + initialIsLast: isRestoringFromCache ? initialCache!.totalIsLast : undefined, }); const myFeed = useInifinieScroll({ @@ -70,46 +72,52 @@ const Feed = () => { isLast: response.data.isLast, }; }, + initialItems: isRestoringFromCache ? initialCache!.myFeedPosts : undefined, + initialCursor: isRestoringFromCache ? initialCache!.myNextCursor || null : undefined, + initialIsLast: isRestoringFromCache ? initialCache!.myIsLast : undefined, }); useEffect(() => { + if (skipScrollResetRef.current) { + skipScrollResetRef.current = false; + return; + } window.scrollTo(0, 0); }, [activeTab]); const currentFeed = activeTab === '피드' ? totalFeed : myFeed; useEffect(() => { - const loadFeedsWithToken = async () => { - await waitForToken(); - - setTabLoading(true); - - try { - const minLoadingTime = new Promise(resolve => setTimeout(resolve, 500)); - - if (activeTab === '피드') { - await Promise.all([loadTotalFeeds(), minLoadingTime]); - } else if (activeTab === '내 피드') { - await Promise.all([loadMyFeeds(), minLoadingTime]); - } - } finally { - setTabLoading(false); - setInitialLoading(false); - } - }; - - loadFeedsWithToken(); - }, [activeTab, waitForToken, loadTotalFeeds, loadMyFeeds]); + const hasItems = totalFeed.items.length > 0 || myFeed.items.length > 0; + if (!hasItems) return; + writeFeedCache({ + activeTab, + totalFeedPosts: totalFeed.items, + myFeedPosts: myFeed.items, + totalNextCursor: totalFeed.nextCursor ?? '', + myNextCursor: myFeed.nextCursor ?? '', + totalIsLast: totalFeed.isLast, + myIsLast: myFeed.isLast, + }); + }, [ + activeTab, + totalFeed.items, + myFeed.items, + totalFeed.nextCursor, + myFeed.nextCursor, + totalFeed.isLast, + myFeed.isLast, + ]); return ( navigate('/feed/search')} + rightButtonClick={() => navigate('/notice')} /> - {initialLoading || tabLoading ? ( + {currentFeed.isLoading ? ( activeTab === '내 피드' ? ( ) : ( @@ -122,23 +130,19 @@ const Feed = () => { ) : ( <> {activeTab === '피드' ? ( - <> - - + ) : ( - <> - - + )} {!currentFeed.isLast &&
} {currentFeed.isLoadingMore && } diff --git a/src/pages/feed/FeedDetailPage.tsx b/src/pages/feed/FeedDetailPage.tsx index 83169c0..ba4daaa 100644 --- a/src/pages/feed/FeedDetailPage.tsx +++ b/src/pages/feed/FeedDetailPage.tsx @@ -44,12 +44,8 @@ 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)), - getComments(Number(feedId), { postType: 'FEED' }), - ]); + const feedResponse = await getFeedDetail(Number(feedId)); await minLoadingTime; setFeedData(feedResponse.data); @@ -141,13 +137,7 @@ const FeedDetailPage = () => { }; const handleBackClick = () => { - window.close(); - - if (window.opener) { - window.close(); - } else { - navigate(-1); - } + navigate(-1); }; if (loading) { @@ -173,12 +163,15 @@ const FeedDetailPage = () => { ); } - if (error) { - return <>; - } - - if (!feedData) { - return <>; + if (error || !feedData) { + return ( + + } + onLeftClick={handleBackClick} + /> + + ); } return ( diff --git a/src/pages/feed/FollowerListPage.tsx b/src/pages/feed/FollowerListPage.tsx index 2d5e8e6..bd4821e 100644 --- a/src/pages/feed/FollowerListPage.tsx +++ b/src/pages/feed/FollowerListPage.tsx @@ -23,44 +23,13 @@ const FollowerListPage = () => { navigate(-1); }; - const loadUserList = useCallback( - async (cursor?: string) => { - if (loading) return; - - try { - setLoading(true); - setError(null); - let response; - - const minLoadingTime = !cursor ? new Promise(resolve => setTimeout(resolve, 500)) : null; - - if (type === 'followerlist') { - if (!userId) { - setError('사용자 ID가 없습니다.'); - return; - } - const [data] = await Promise.all([ - getFollowerList(userId, { size: 10, cursor: cursor || null }), - ]); - response = data; - } else { - const [data] = await Promise.all([ - getFollowingList({ size: 10, cursor: cursor || null }), - ]); - response = data; - } - await minLoadingTime; - - let userData: FollowData[] = []; - if (type === 'followerlist') { - userData = (response.data as { followers: FollowData[] })?.followers || []; - } else { - userData = (response.data as { followings: FollowData[] })?.followings || []; - } - - if (!response || !response.data) { - setError('API 응답이 없습니다.'); - return; + const userList = useInifinieScroll({ + enabled: true, + reloadKey: `${type ?? ''}-${userId ?? ''}`, + fetchPage: async cursor => { + if (type === 'followerlist') { + if (!userId) { + return { items: [], nextCursor: null, isLast: true }; } const response = await getFollowerList(userId, { size: 10, cursor }); const total = response.data.totalFollowerCount; @@ -91,9 +60,13 @@ const FollowerListPage = () => { return ( - } onLeftClick={handleBackClick} title={title} /> + } + onLeftClick={handleBackClick} + title={title} + /> 전체 {totalCount} - {loading && userList.length === 0 ? ( + {userList.isLoading && userList.items.length === 0 ? ( {Array.from({ length: 5 }).map((_, i) => ( diff --git a/src/pages/groupSearch/GroupSearch.tsx b/src/pages/groupSearch/GroupSearch.tsx index c96ba15..4b3ebb1 100644 --- a/src/pages/groupSearch/GroupSearch.tsx +++ b/src/pages/groupSearch/GroupSearch.tsx @@ -6,12 +6,11 @@ import rightChevron from '../../assets/common/right-Chevron.svg'; 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 } from '@/api/rooms/getSearchRooms'; +import { getSearchRooms, type SearchRoomItem } from '@/api/rooms/getSearchRooms'; import { useNavigate, useLocation } from 'react-router-dom'; -import { AllRoomsButton, LoadingMessage } from './GroupSearch.styled'; +import { AllRoomsButton } from './GroupSearch.styled'; import { useInifinieScroll } from '@/hooks/useInifinieScroll'; import { GroupCardSkeleton, RecentSearchTabsSkeleton } from '@/shared/ui/Skeleton'; import { Content } from '@/components/search/GroupSearchResult.styled'; @@ -43,7 +42,6 @@ const GroupSearch = () => { useEffect(() => { fetchRecentSearches(); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { @@ -65,55 +63,6 @@ const GroupSearch = () => { } }; - const searchFirstPage = useCallback( - async ( - term: string, - sortKey: SortKey, - status: 'searching' | 'searched', - categoryParam: string, - isAllCategory: boolean = false, - keepPrevious: boolean = false, - ) => { - setIsLoading(true); - setError(null); - if (!keepPrevious) { - setRooms([]); - setNextCursor(null); - setIsLast(true); - } - - try { - const isFinalized = status === 'searched'; - const minLoadingTime = new Promise(resolve => setTimeout(resolve, 500)); - const [res] = await Promise.all([ - getSearchRooms( - term.trim(), - sortKey, - undefined, - isFinalized, - categoryParam, - isAllCategory, - ), - ]); - await minLoadingTime; - if (res.isSuccess) { - const { roomList, nextCursor: nc, isLast: last } = res.data; - - setRooms(roomList); - setNextCursor(nc); - setIsLast(last); - } else { - setError(res.message || '검색 실패'); - } - } catch { - setError('네트워크 오류가 발생했습니다.'); - } finally { - setIsLoading(false); - } - }, - [], - ); - useEffect(() => { if (location.state?.allRooms) { navigate(location.pathname, { replace: true }); @@ -141,7 +90,7 @@ const GroupSearch = () => { setSearchStatus('searching'); setShowTabs(false); const id = setTimeout(() => { - searchFirstPage(trimmed, toSortKey(selectedFilter), 'searching', category, false, true); + setDebouncedSearchTerm(trimmed); }, 300); setSearchTimeoutId(id); }; @@ -180,85 +129,29 @@ const GroupSearch = () => { setCategory(''); }; - const searchStatusRef = useRef(searchStatus); - const categoryRef = useRef(category); - const selectedFilterRef = useRef(selectedFilter); - const searchTermRef = useRef(searchTerm); - - useEffect(() => { - searchStatusRef.current = searchStatus; - categoryRef.current = category; - selectedFilterRef.current = selectedFilter; - searchTermRef.current = searchTerm; - }); - - useEffect(() => { - if (searchStatus !== 'searched') return; - - const term = searchTermRef.current.trim(); - const currentCategory = categoryRef.current; - const isAllCategory = !term && currentCategory === ''; - - searchFirstPage( - term, - toSortKey(selectedFilterRef.current), - 'searched', - currentCategory, - isAllCategory, - true, - ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchStatus, searchTerm]); - - useEffect(() => { - if (searchStatusRef.current !== 'searched') return; - - const term = searchTermRef.current.trim(); - const isAllCategory = !term && category === ''; - - searchFirstPage(term, toSortKey(selectedFilter), 'searched', category, isAllCategory, true); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedFilter, category]); - useEffect(() => { if (searchStatus === 'searched') { setDebouncedSearchTerm(searchTerm.trim()); } }, [searchStatus, searchTerm]); - const id = setTimeout(() => { - const currentCategory = categoryRef.current; - searchFirstPage(term, toSortKey(selectedFilter), 'searching', currentCategory, false, true); - }, 300); - setSearchTimeoutId(id); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchTerm, searchStatus, selectedFilter]); - - const loadMore = useCallback(async () => { - const trimmedTerm = searchTerm.trim(); - const isAllCategory = !trimmedTerm && category === ''; - if ((!isAllCategory && !trimmedTerm) || !nextCursor || isLast || isLoadingMore) return; - try { - setIsLoadingMore(true); + const searchResult = useInifinieScroll({ + enabled: searchStatus === 'searched' || (searchStatus === 'searching' && !!debouncedSearchTerm), + reloadKey: `${debouncedSearchTerm}-${selectedFilter}-${category}-${searchStatus}`, + fetchPage: async cursor => { const isFinalized = searchStatus === 'searched'; - const isAllCategory = !queryTerm && category === ''; - if (searchStatus === 'searching' && !queryTerm) { - return { items: [], nextCursor: null, isLast: true }; - } - + const isAllCategory = !debouncedSearchTerm && category === ''; const res = await getSearchRooms( - queryTerm, + debouncedSearchTerm, toSortKey(selectedFilter), cursor ?? undefined, isFinalized, category, isAllCategory, ); - if (!res.isSuccess) { throw new Error(res.message || '검색 실패'); } - return { items: res.data.roomList, nextCursor: res.data.nextCursor, @@ -317,7 +210,7 @@ const GroupSearch = () => { {searchStatus !== 'idle' ? ( <> - {isLoading && rooms.length === 0 ? ( + {searchResult.isLoading && searchResult.items.length === 0 ? ( {Array.from({ length: 5 }).map((_, i) => ( diff --git a/src/pages/memory/Memory.tsx b/src/pages/memory/Memory.tsx index 1775b0e..16c332d 100644 --- a/src/pages/memory/Memory.tsx +++ b/src/pages/memory/Memory.tsx @@ -6,7 +6,6 @@ import MemoryContent from '../../components/memory/MemoryContent/MemoryContent'; import MemoryAddButton from '../../components/memory/MemoryAddButton/MemoryAddButton'; import Snackbar from '../../components/common/Modal/Snackbar'; import GlobalCommentBottomSheet from '../../components/common/CommentBottomSheet/GlobalCommentBottomSheet'; -import LoadingSpinner from '../../components/common/LoadingSpinner'; import { useCommentBottomSheetStore } from '@/stores/commentBottomSheetStore'; import { useInifinieScroll } from '@/hooks/useInifinieScroll'; import { Container, FixedHeader, ScrollableContent, FloatingElements } from './Memory.styled'; @@ -89,9 +88,6 @@ const Memory = () => { } }, [location.search]); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(true); - const [showUploadProgress, setShowUploadProgress] = useState(false); const [roomCompleted, setRoomCompleted] = useState(false); @@ -109,42 +105,26 @@ const Memory = () => { return { items: [], nextCursor: null, isLast: true }; } - const loadMemoryPosts = useCallback(async () => { - if (!roomId) { - return; - } - - if (activeFilter === 'page' && !selectedPageRange) { - return; - } - - setError(null); - setLoading(true); - - try { - const params: GetMemoryPostsParams = { - roomId: parseInt(roomId, 10), - type: activeTab === 'group' ? 'group' : 'mine', - cursor, - }; - - if (activeTab === 'group') { - params.sort = selectedSort; - } + try { + const params: GetMemoryPostsParams = { + roomId: parseInt(roomId, 10), + type: activeTab === 'group' ? 'group' : 'mine', + cursor, + }; - if (activeFilter === 'overall') { - params.isOverview = true; - } else if (selectedPageRange) { - params.pageStart = selectedPageRange.start; - params.pageEnd = selectedPageRange.end; - params.isPageFilter = true; - } + if (activeTab === 'group') { + params.sort = selectedSort; + } - const minLoadingTime = new Promise(resolve => setTimeout(resolve, 500)); - const [response] = await Promise.all([getMemoryPosts(params), minLoadingTime]); + if (activeFilter === 'overall') { + params.isOverview = true; + } else if (selectedPageRange) { + params.pageStart = selectedPageRange.start; + params.pageEnd = selectedPageRange.end; + params.isPageFilter = true; + } - if (response.isSuccess) { - const convertedRecords = response.data.postList.map(convertPostToRecord); + const [response] = await Promise.all([getMemoryPosts(params)]); if (!response.isSuccess) { throw new Error(response.message || '기록을 불러오는 중 오류가 발생했습니다.'); @@ -172,12 +152,8 @@ const Memory = () => { } throw new Error('기록을 불러오는 중 오류가 발생했습니다.'); } - - setError('기록을 불러오는 중 오류가 발생했습니다.'); - } finally { - setLoading(false); - } - }, [roomId, activeTab, selectedSort, activeFilter, selectedPageRange]); + }, + }); useEffect(() => { const checkRoomStatus = async () => { @@ -222,11 +198,11 @@ const Memory = () => { if (location.state?.newRecord) { const newRecord = location.state.newRecord as Record; setShowUploadProgress(true); - setRecordItems(prev => [newRecord, ...prev]); + recordsList.setItems(prev => [newRecord, ...prev]); navigate(location.pathname, { replace: true }); } - }, [location.state, navigate, location.pathname, setRecordItems]); + }, [location.state, navigate, location.pathname]); const currentRecords = useMemo(() => recordsList.items, [recordsList.items]); @@ -299,13 +275,9 @@ const Memory = () => { const handleRecordDelete = useCallback( (id: string) => { - if (activeTab === 'group') { - setGroupRecords(prev => prev.filter(r => r.id !== id)); - } else { - setMyRecords(prev => prev.filter(r => r.id !== id)); - } + recordsList.setItems(prev => prev.filter(r => r.id !== id)); }, - [activeTab], + [recordsList], ); const readingProgress = totalPages > 0 ? Math.round((currentUserPage / totalPages) * 100) : 0; @@ -332,7 +304,7 @@ const Memory = () => { - {loading ? ( + {recordsList.isLoading ? ( diff --git a/src/pages/mypage/SavePage.tsx b/src/pages/mypage/SavePage.tsx index a953466..2958d94 100644 --- a/src/pages/mypage/SavePage.tsx +++ b/src/pages/mypage/SavePage.tsx @@ -69,121 +69,6 @@ const SavePage = () => { navigate('/mypage'); }; - const loadSavedBooks = useCallback(async (cursor: string | null = null) => { - try { - setBookLoading(true); - const response = await getSavedBooksInMy(cursor); - - if (cursor === null) { - setSavedBooks(response.data.bookList); - } else { - setSavedBooks(prev => [...prev, ...response.data.bookList]); - } - - setBookNextCursor(response.data.nextCursor); - setBookIsLast(response.data.isLast); - } catch (error) { - console.error('저장된 책 목록 로드 실패:', error); - } finally { - setBookLoading(false); - } - }, []); - - const loadSavedFeeds = useCallback(async (cursor: string | null = null) => { - try { - setFeedLoading(true); - const response = await getSavedFeedsInMy(cursor); - - if (cursor === null) { - setSavedFeeds(response.data.feedList); - } else { - setSavedFeeds(prev => [...prev, ...response.data.feedList]); - } - - setFeedNextCursor(response.data.nextCursor); - setFeedIsLast(response.data.isLast); - } catch (error) { - console.error('저장된 피드 로드 실패:', error); - } finally { - setFeedLoading(false); - } - }, []); - - const loadMoreBooks = useCallback(async () => { - if (!bookNextCursor || bookIsLast || bookLoading) return; - - try { - await loadSavedBooks(bookNextCursor); - } catch (error) { - console.error('책 추가 로드 실패:', error); - } - }, [bookNextCursor, bookIsLast, bookLoading, loadSavedBooks]); - - const lastBookElementCallback = useCallback( - (node: HTMLDivElement | null) => { - if (bookLoading || bookIsLast) return; - - if (node) { - const observer = new IntersectionObserver(entries => { - if (entries[0].isIntersecting && !bookLoading && !bookIsLast) { - loadMoreBooks(); - } - }); - - observer.observe(node); - } - }, - [bookLoading, bookIsLast, loadMoreBooks], - ); - - useEffect(() => { - const loadAllData = async () => { - try { - setInitialLoading(true); - - const minLoadingTime = new Promise(resolve => setTimeout(resolve, 500)); - const [feedsResponse, booksResponse] = await Promise.all([ - getSavedFeedsInMy(null), - getSavedBooksInMy(), - ]); - await minLoadingTime; - - setSavedFeeds(feedsResponse.data.feedList); - setFeedNextCursor(feedsResponse.data.nextCursor); - setFeedIsLast(feedsResponse.data.isLast); - - setSavedBooks(booksResponse.data.bookList); - setBookNextCursor(booksResponse.data.nextCursor); - setBookIsLast(booksResponse.data.isLast); - } catch (error) { - console.error('초기 데이터 로드 실패:', error); - } finally { - setInitialLoading(false); - } - }; - - loadAllData(); - }, []); - - useEffect(() => { - const observer = new IntersectionObserver( - entries => { - entries.forEach(entry => { - if (entry.isIntersecting && !feedIsLast && !feedLoading && feedNextCursor) { - loadSavedFeeds(feedNextCursor); - } - }); - }, - { threshold: 0.1 }, - ); - - if (feedObserverRef.current) { - observer.observe(feedObserverRef.current); - } - - return () => observer.disconnect(); - }, [feedIsLast, feedLoading, feedNextCursor, loadSavedFeeds]); - useEffect(() => { window.scrollTo(0, 0); }, [activeTab]); @@ -230,7 +115,7 @@ const SavePage = () => { title="저장" /> - {initialLoading ? ( + {showInitialLoading ? ( activeTab === '피드' ? ( {Array.from({ length: 3 }).map((_, index) => ( @@ -291,7 +176,10 @@ const SavePage = () => { handleSaveToggle(book.isbn)}> - {book.isSaved + {book.isSaved ))} diff --git a/src/pages/searchBook/SearchBook.tsx b/src/pages/searchBook/SearchBook.tsx index 804e6b8..dfedd30 100644 --- a/src/pages/searchBook/SearchBook.tsx +++ b/src/pages/searchBook/SearchBook.tsx @@ -21,6 +21,7 @@ import { EmptyTitle, EmptySubText, FeedPostContainer, + LoadingBox, } from './SearchBook.styled'; import { useNavigate, useParams } from 'react-router-dom'; import leftArrow from '../../assets/common/leftArrow.svg'; @@ -103,64 +104,18 @@ const SearchBook = () => { fetchBookDetail(); }, [isbn]); - const loadFirstFeeds = useCallback(async () => { - if (!isbn) return; - try { - setIsLoadingFeeds(true); - setFeeds([]); - setNextCursor(null); - setIsLast(true); - - const minLoadingTime = new Promise(resolve => setTimeout(resolve, 500)); - const [res] = await Promise.all([ - getFeedsByIsbn(isbn, toFeedSort(selectedFilter), null), - ]); - await minLoadingTime; - if (res.isSuccess) { - setFeeds(res.data.feeds); - setNextCursor(res.data.nextCursor); - setIsLast(res.data.isLast); - } - } catch { - // no-op - } finally { - setIsLoadingFeeds(false); - } - }, [isbn, selectedFilter]); - - useEffect(() => { - loadFirstFeeds(); - }, [loadFirstFeeds]); - - const loadMore = useCallback(async () => { - if (!isbn || !nextCursor || isLast || isLoadingMore) return; - try { - setIsLoadingMore(true); - const res = await getFeedsByIsbn(isbn, toFeedSort(selectedFilter), nextCursor); - if (res.isSuccess) { - setFeeds(prev => [...prev, ...res.data.feeds]); - setNextCursor(res.data.nextCursor); - setIsLast(res.data.isLast); - } - } catch { - // no-op - } finally { - setIsLoadingMore(false); - } - }, [isbn, nextCursor, isLast, isLoadingMore, selectedFilter]); - - const lastFeedElementCallback = 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); + const feeds = useInifinieScroll({ + enabled: !!isbn, + reloadKey: `${isbn ?? ''}-${selectedFilter}`, + fetchPage: async cursor => { + if (!isbn) return { items: [], nextCursor: null, isLast: true }; + const res = await getFeedsByIsbn(isbn, toFeedSort(selectedFilter), cursor ?? null); + if (!res.isSuccess) throw new Error(res.message || '피드 로드 실패'); + return { + items: res.data.feeds, + nextCursor: res.data.nextCursor, + isLast: res.data.isLast, + }; }, rootMargin: '100px 0px', threshold: 0.1, @@ -245,9 +200,7 @@ const SearchBook = () => {
-
- {error} -
+
{error}
); } @@ -275,21 +228,21 @@ const SearchBook = () => { {bookDetail.description} - - - 모집중인 모임방 {recruitingRoomsData?.totalRoomCount || 0}개{' '} - 오른쪽 화살표 아이콘 - - - - 피드에 글쓰기 더하기 아이콘 - - - 저장 버튼 - - - - + + + 모집중인 모임방 {recruitingRoomsData?.totalRoomCount || 0}개{' '} + 오른쪽 화살표 아이콘 + + + + 피드에 글쓰기 더하기 아이콘 + + + 저장 버튼 + + + + )} @@ -302,13 +255,13 @@ const SearchBook = () => { setSelectedFilter={filter => setSelectedFilter(filter as (typeof FILTER)[number])} /> - {isLoadingFeeds && feeds.length === 0 ? ( + {feeds.isLoading && feeds.items.length === 0 ? ( {Array.from({ length: 3 }).map((_, i) => ( ))} - ) : feeds.length > 0 ? ( + ) : feeds.items.length > 0 ? ( {feeds.items.map(post => (