-
Notifications
You must be signed in to change notification settings - Fork 3
CardList, Card
const LIMIT = 3;
function CardList({ isEditMode, id }) {
const [cards, setCards] = useState([]);
const [hasNext, setHasNext] = useState(true);
const [deleteId, setDeleteId] = useState(null);
const [offset, setOffset] = useState(0);
const target = useRef(null);
const { isLoading, fetch: fetchLoad } = useRequest({
skip: true,
options: {
url: `/recipients/${id}/messages/`,
params: {
limit: LIMIT,
offset: offset,
},
},
});
const { fetch: fetchDelete } = useRequest({
skip: true,
options: {
url: `/messages/${deleteId}/`,
method: "DELETE",
},
});
const loadData = async () => {
if (!hasNext) return;
const { data: cards, error } = await fetchLoad();
if (error) {
throw new Error("메시지 카드 불러오기 실패");
}
const currentCards = cards?.results;
setCards((prevCards) => [...prevCards, ...currentCards]);
setOffset((prevOffset) => prevOffset + LIMIT);
if (!cards?.next) setHasNext(false);
};
const deleteCard = async () => {
if (!deleteId) return;
const { error } = await fetchDelete();
if (error) {
throw new Error("메시지 카드 삭제 실패");
}
setCards((prevCards) => prevCards.filter((card) => card.id !== +deleteId));
};
const getDeleteCardId = (e) => {
e.stopPropagation();
setDeleteId(e.currentTarget.id);
};
const onIntersect = async ([entry], observer) => {
if (entry.isIntersecting && !isLoading) {
observer.unobserve(entry.target);
await loadData();
if (hasNext && target.current) {
observer.observe(target.current);
}
}
};
useEffect(() => {
const observer = new IntersectionObserver(onIntersect, { threshold: 0.1 });
if (target.current) {
observer.observe(target.current);
}
return () => observer.disconnect();
}, [offset, target]);
useEffect(() => {
deleteCard();
}, [deleteId]);
return (
<>
<S.ListContainer>
{!isEditMode && (
<S.ButtonContainer>
<Link to={`/post/${id}/message`}>
<Button.Add />
</Link>
</S.ButtonContainer>
)}
{cards &&
cards.map((card) => {
return (
<S.CardContainer key={card?.id}>
<Card
item={card}
isEditMode={isEditMode}
getDeleteCardId={getDeleteCardId}
/>
</S.CardContainer>
);
})}
</S.ListContainer>
{hasNext && !isLoading && <S.ScrollTarget ref={target}></S.ScrollTarget>}
</>
);
}function Card({ item, isEditMode, getDeleteCardId }) {
const [modalIsOpen, setModalIsOpen] = useState(false);
const relationship = RELATIONSHIP[item.relationship];
const date = formatDate(item.createdAt);
const content = {
__html: DOMPurify.sanitize(item.content),
};
const handleClickCard = () => {
if (window.innerWidth > 768) {
setModalIsOpen(true);
}
};
const handleClickBackdropOrButton = () => {
setModalIsOpen(false);
};
return (
<>
<div onClick={handleClickCard}>
<S.Profile>
<S.ProfileImage src={item.profileImageURL} alt="프로필 이미지" />
<S.ProfileText>
<S.Sender>
From.<span>{item.sender}</span>
</S.Sender>
<Relationship relationship={relationship} />
</S.ProfileText>
</S.Profile>
{isEditMode && (
<S.IconContainer id={item.id} onClick={getDeleteCardId}>
<img src={deleteIcon} alt="카드 삭제 버튼" />
</S.IconContainer>
)}
<S.Content>
<S.Message $font={item.font} dangerouslySetInnerHTML={content} />
<S.Date>{date}</S.Date>
</S.Content>
</div>
{modalIsOpen && (
<Modal
close={handleClickBackdropOrButton}
item={item}
relationship={relationship}
content={content}
date={date}
/>
)}
</>
);
}-
디자인 상 프로젝트에서 카드 컴포넌트가 사용되는 경우가 롤링 페이퍼 페이지에서 사용되기 때문에 그 점을 고려해서 구현했습니다.
-
카드 리스트 컴포넌트는 prop으로
isEditMode와id를 받아서,isEditMode의 값에 따라 화면에서+버튼을 가지는 카드가 조건부 렌더링되도록 했습니다. 추가로 자식 컴포넌트인Card에도 그대로isEditModeprop을 내려줍니다. -
카드 리스트 컴포넌트는 메세지 카드를 화면에 렌더링하기 위해 데이터를 서버에서 불러오는데, 한 번에 모든 데이터를 불러오는 것이 아니라 인터섹션 옵저버를 사용해서 카드 리스트 아래에 있는 타겟 영역을 감시하도록 하고, 이 영역이 화면에 보이게 되면 데이터를 불러오는 동작을 실행하고,
offset과 데이터 배열인cardsstate를 업데이트합니다. -
메세지 카드를 삭제하는 기능을 위해,
getDeleteCardId함수를Card컴포넌트에 prop으로 내려줌으로써 삭제하려고 하는Card컴포넌트의 고유한id를 받아와서 서버에 삭제 요청을 보내고cardsstate를 업데이트합니다. -
카드 컴포넌트는 prop으로
isEditMode와getDeleteCardId, 렌더링에 필요한 데이터인item을 받습니다. -
item객체가 가지는 프로퍼티들을 사용해 메세지 카드를 적절하게 렌더링합니다. -
isEditMode의 값에 따라 메세지 카드를 삭제할 수 있는 버튼이 조건부 렌더링되도록 했습니다. -
카드가 클릭되면 카드를 확대해서 보여주는
Modal컴포넌트가 렌더링됩니다. 이때 모바일 사이즈에서는 모달이 열릴 수 없도록window.innerWidth의 값을 확인합니다.
const { isLoading, fetch: fetchLoad } = useRequest({
skip: true,
options: {
url: `/recipients/${id}/messages/`,
params: {
limit: LIMIT,
offset: offset,
},
},
});useRequest 훅을 사용해서 데이터를 불러오는 fetchLoad 함수와 로딩 상태를 확인할 수 있게 해주는 state isLoading을 정의합니다.
const loadData = async () => {
if (!hasNext) return;
const { data: cards, error } = await fetchLoad();
if (error) {
throw new Error("메시지 카드 불러오기 실패");
}
const currentCards = cards?.results;
setCards((prevCards) => [...prevCards, ...currentCards]);
setOffset((prevOffset) => prevOffset + LIMIT);
if (!cards?.next) setHasNext(false);
};loadData 함수는 LIMIT만큼 서버에서 메세지 카드 데이터를 불러옵니다.
성공적으로 데이터를 받아온 경우 기존 cards 데이터 배열에 새로 받아온 데이터 배열을 추가해 새로운 cards 배열을 state에 넣어줍니다.
offset state를 업데이트해서 이미 받아온 데이터를 제외하고 다시 LIMIT만큼 데이터를 받아올 수 있도록 합니다.
불러온 데이터의 next 프로퍼티가 null이라면 hasNext state를 false로 만들어 더 이상 함수를 실행할 수 없도록 합니다.
const target = useRef(null);
...
const onIntersect = async ([entry], observer) => {
if (entry.isIntersecting && !isLoading) {
observer.unobserve(entry.target);
await loadData();
if (hasNext && target.current) {
observer.observe(target.current);
}
}
};
useEffect(() => {
const observer = new IntersectionObserver(onIntersect, { threshold: 0.1 });
if (target.current) {
observer.observe(target.current);
}
return () => observer.disconnect();
}, [offset, target]);
...
return (
<>
<S.ListContainer>
...
</S.ListContainer>
{hasNext && !isLoading && <S.ScrollTarget ref={target}></S.ScrollTarget>}
</>
);
}IntersectionObserver API를 활용해서 무한 스크롤 기능을 구현했습니다.
useRef 훅을 사용해서 target이 항상 화면의 맨 아래에 위치하는 ScrollTarget 컴포넌트를 참조하도록 합니다.
이 ScrollTarget 컴포넌트는 hasNext가 true이고, 로딩 중이 아닐 때만 화면에 렌더링됩니다.
(중복된 요청을 여러번 보내는 것을 막기 위함)
onIntersect 함수는 인터섹션 옵저버가 관찰하는 대상 entry.target이 화면에 들어왔을 때 호출됩니다.
이미 데이터를 로딩 중인 상태가 아니라면, 대상의 관찰을 해제하고 loadData 함수를 호출합니다.
이후에는 hasNext가 false가 아니고 관찰할 대상이 존재한다면 해당 대상을 관찰합니다.
useEffect 훅은 offset이나 target이 변경될 때마다 실행됩니다.
IntersectionObserver 인스턴스를 생성하고 target.current를 관찰 대상으로 설정합니다.
컴포넌트가 언마운트될 때는 모든 관찰 대상 관찰을 중단하는 클린업 함수를 반환합니다.
- MainPage : 메인 페이지
- PaperListPage : 롤링 페이퍼 목록 페이지
- CreatePaperPage : 롤링 페이퍼 생성 페이지
- MessageListPage : 롤링 페이퍼 페이지
- CreateMessagePage : 메시지 생성 페이지