Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@
"dayjs": "^1.11.12",
"firebase": "^10.13.0",
"heic2any": "^0.0.4",
"lodash": "^4.17.21",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-responsive": "^10.0.0",
"react-responsive": "^10.0.0",
"react-router-dom": "^6.24.1",
"recoil": "^0.7.7",
"recoil-persist": "^5.1.0",
Expand All @@ -29,6 +30,7 @@
"swiper": "^11.1.8"
},
"devDependencies": {
"@types/lodash": "^4.17.13",
"@types/node": "^20.14.10",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
Expand Down
4 changes: 2 additions & 2 deletions src/pages/Home/HomeTopBar/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from 'react';
import { Button, ButtonContainer, HomeLogo, HomeTopBarContainer } from './styles';
import logo from '@assets/default/oodd.svg';
import alarm from '@assets/default/alarm.svg';
import Alarm from '@/components/Icons/Alarm';

// Home 페이지의 상단 바입니다. 로고와 알림이 있습니다.
// TODO: 버튼 클릭 이벤트 처리 필요
Expand All @@ -11,7 +11,7 @@ const HomeTopBar: React.FC = () => {
<HomeLogo src={logo} alt="oodd" />
<ButtonContainer>
<Button>
<img src={alarm} alt="알림" />
<Alarm />
</Button>
</ButtonContainer>
</HomeTopBarContainer>
Expand Down
16 changes: 7 additions & 9 deletions src/pages/Home/OOTD/Feed/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,11 @@ import {
} from './styles';
import more from '@assets/default/more.svg';
import xBtn from '@assets/default/reject.svg';
import likeBtn from '@assets/default/heart.svg';
import likeFillBtn from '@assets/default/heart-fill.svg';
import commentBtn from '@assets/default/message-white.svg';
import { useNavigate } from 'react-router-dom';
import defaultProfile from '@assets/default/defaultProfile.svg';
import dayjs from 'dayjs';
import Heart from '@/components/Icons/Heart';
import Message from '@/components/Icons/Message';
import { OptionsBottomSheetProps } from '@components/BottomSheet/OptionsBottomSheet/dto';
import OptionsBottomSheet from '@components/BottomSheet/OptionsBottomSheet';
import CommentBottomSheet from '@components/CommentBottomSheet';
Expand Down Expand Up @@ -240,14 +239,13 @@ const Feed: React.FC<FeedProps> = ({ feed }) => {
<ReactionWrapper>
<Reaction>
<img className="button" onClick={handleRejectButtonClick} src={xBtn} />
{isLikeClicked ? (
<img className="button" onClick={handleLikeButtonClick} src={likeFillBtn} />
) : (
<img className="button" onClick={handleLikeButtonClick} src={likeBtn} />
)}
<div className="button" onClick={handleLikeButtonClick}>
{/* Heart 컴포넌트의 isFilled 프로퍼티에 isLikeClicked 상태를 전달 */}
<Heart isFilled={isLikeClicked} />
</div>
</Reaction>
<MatchingBtn onClick={handleMatchingButtonClick}>
<img src={commentBtn} />
<Message color="white" />
<StyledText $textTheme={{ style: 'body1-regular' }} color={theme.colors.white}>
매칭 요청
</StyledText>
Expand Down
86 changes: 63 additions & 23 deletions src/pages/Home/OOTD/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef, useLayoutEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import debounce from 'lodash/debounce';
import { OOTDContainer, FeedContainer } from './styles';
import Feed from './Feed/index';
import { getPostListApi } from '@apis/post';
Expand All @@ -13,34 +14,35 @@ const OOTD: React.FC = () => {
const [modalContent, setModalContent] = useState('알 수 없는 오류입니다.\n관리자에게 문의해 주세요.');
const [isStatusModalOpen, setIsStatusModalOpen] = useState(false);

const [reachedEnd, setReachedEnd] = useState(false);
// API 요청 중인지 확인하는 변수 (렌더링 없이 상태 관리)
const isFetchingRef = useRef(false);
// 모든 데이터를 불러왔는지 확인하는 변수
const isReachedEndRef = useRef(false);

// 현재 페이지 번호를 참조하는 변수, 리렌더링 없이 값만 업데이트하기 위해 상태가 아닌 useRef 사용
const feedPageRef = useRef(1);

// 세션 스토리지에서 이전 스크롤 위치를 가져와 초기화
const savedScrollPosition = sessionStorage.getItem('scrollPosition');
const scrollPositionRef = useRef(Number(savedScrollPosition) || 0);

// 스크롤 이벤트 핸들러 추가
const handleScroll = () => {
// 모든 데이터를 불러왔거나 아직 렌더링이 다 안 된 경우 반환
if (reachedEnd || isFetchingRef.current) return;

if (window.innerHeight + document.documentElement.scrollTop >= document.body.scrollHeight - window.innerHeight) {
isFetchingRef.current = true;
scrollPositionRef.current = window.scrollY; // 현재 스크롤 위치 저장
getPostList();
}
};
// IntersectionObserver 인스턴스를 참조하는 변수
const observerRef = useRef<IntersectionObserver | null>(null);
// 더 많은 데이터를 로드할 때 관찰할 마지막 요소의 DOM을 참조
const loadMoreRef = useRef<HTMLDivElement | null>(null);

// 전체 게시글(피드) 조회 api
// 전체 게시글(피드) 조회 API
const getPostList = async () => {
if (reachedEnd) return;
// 모든 데이터를 불러왔거나 요청 중이라면 함수 실행 중단
if (isReachedEndRef.current || isFetchingRef.current) return;

isFetchingRef.current = true; // 요청 중임을 표시
try {
const response = await getPostListApi(feedPageRef.current, 20);

if (response.isSuccess) {
if (response.data.post.length === 0) {
setReachedEnd(true);
isReachedEndRef.current = true; // 더 이상 불러올 데이터가 없음을 표시
} else {
setFeeds((prevFeeds) => [...prevFeeds, ...response.data.post]);
feedPageRef.current += 1;
Expand All @@ -50,22 +52,58 @@ const OOTD: React.FC = () => {
const errorMessage = handleError(error);
setModalContent(errorMessage);
setIsStatusModalOpen(true);
} finally {
isFetchingRef.current = false;
}
};

useEffect(() => {
getPostList();
window.addEventListener('scroll', handleScroll);
if (isReachedEndRef.current && observerRef.current && loadMoreRef.current) {
observerRef.current.unobserve(loadMoreRef.current); // 데이터의 끝에 다다르면 옵저버 해제 (더이상 피드가 없으면)
return;
}

// Intersection Observer 생성
observerRef.current = new IntersectionObserver(
debounce((entries) => {
const target = entries[0];
console.log('Intersection Observer:', target.isIntersecting);
if (target.isIntersecting && !isFetchingRef.current && !isReachedEndRef.current) {
// 요소가 화면에 보이고 있고, 요청 중이 아니며 끝에 도달하지 않았다면 API 호출
getPostList();
}
}, 300), // 디바운스 적용, 마지막 스크롤 이후 300ms 동안 동작이 없으면 이벤트 호출
{
root: null,
rootMargin: '100px', // 요소가 보이기 100px 전에 미리 데이터 로드
threshold: 0, // 요소가 아주 조금이라도 보이면 트리거
},
);

// 옵저버를 마지막 요소에 연결
if (loadMoreRef.current) {
observerRef.current.observe(loadMoreRef.current);
}
return () => {
window.removeEventListener('scroll', handleScroll);
// 컴포넌트 언마운트 시 옵저버 해제
if (observerRef.current && loadMoreRef.current) {
observerRef.current.unobserve(loadMoreRef.current);
}
};
}, []);

useLayoutEffect(() => {
window.scrollTo(0, scrollPositionRef.current); // 저장된 스크롤 위치로 이동
isFetchingRef.current = false;
}, [feeds]); // feeds가 변경될 때 실행
useEffect(() => {
// 첫 로드 시 API 호출
getPostList();

// 세션 저장된 이전 스크롤 위치 복원
window.scrollTo(0, scrollPositionRef.current);

return () => {
// 컴포넌트 언마운트 시 현재 스크롤 위치를 세션 스토리지에 저장
sessionStorage.setItem('scrollPosition', String(window.scrollY));
};
}, []);

const statusModalProps: ModalProps = {
content: modalContent,
Expand All @@ -82,6 +120,8 @@ const OOTD: React.FC = () => {
<Feed feed={feed} />
</div>
))}
{/* Intersection Observer가 감지할 마지막 요소 */}
<div ref={loadMoreRef} style={{ height: '1px', backgroundColor: 'transparent' }} />
</FeedContainer>
{isStatusModalOpen && <Modal {...statusModalProps} />}
</OOTDContainer>
Expand Down
10 changes: 10 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1068,6 +1068,11 @@
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==

"@types/lodash@^4.17.13":
version "4.17.13"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.13.tgz#786e2d67cfd95e32862143abe7463a7f90c300eb"
integrity sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg==

"@types/node@>=12.12.47", "@types/node@>=13.7.0":
version "22.10.2"
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.10.2.tgz#a485426e6d1fdafc7b0d4c7b24e2c78182ddabb9"
Expand Down Expand Up @@ -1927,6 +1932,11 @@ lodash.merge@^4.6.2:
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==

lodash@^4.17.21:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==

long@^5.0.0:
version "5.2.3"
resolved "https://registry.yarnpkg.com/long/-/long-5.2.3.tgz#a3ba97f3877cf1d778eccbcb048525ebb77499e1"
Expand Down
Loading