Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FE] feat : 이미지 스켈레톤 적용 #1055

Merged
merged 8 commits into from
Jan 16, 2025
1 change: 1 addition & 0 deletions frontend/src/components/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from './common';
export * from './error';
export * from './highlight/components';
export * from './login';
export * from './skeleton';
33 changes: 33 additions & 0 deletions frontend/src/components/skeleton/ImgWithSkeleton/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import React, { useState } from 'react';

import * as S from './style';

interface ImgWithSkeletonProps {
children: React.ReactElement<React.ImgHTMLAttributes<HTMLImageElement>>;
imgWidth: string;
imgHeight: string;
}

const ImgWithSkeleton = ({ children, imgWidth, imgHeight }: ImgWithSkeletonProps) => {
const [isLoaded, setIsLoaded] = useState(false);

const handleImgLoad = (event: React.SyntheticEvent<HTMLImageElement, Event>) => {
if (children.props.onLoad) {
children.props.onLoad(event);
}
setIsLoaded(true);
};

return (
<S.Container $width={imgWidth} $height={imgHeight}>
{!isLoaded && <S.ImgSkeleton />}
<S.ImgWrapper $isLoaded={isLoaded}>
{React.cloneElement(children, {
onLoad: (event: React.SyntheticEvent<HTMLImageElement, Event>) => handleImgLoad(event),
})}
</S.ImgWrapper>
</S.Container>
);
};

export default ImgWithSkeleton;
47 changes: 47 additions & 0 deletions frontend/src/components/skeleton/ImgWithSkeleton/style.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import styled from '@emotion/styled';

interface ContainerProps {
$width: string;
$height: string;
}
export const Container = styled.div<ContainerProps>`
position: relative;
width: ${(props) => props.$width};
height: ${(props) => props.$height};
`;
export const ImgWrapper = styled.div<{ $isLoaded: boolean }>`
position: absolute;
top: 0;
left: 0;

width: 100%;
height: 100%;

opacity: ${(props) => (props.$isLoaded ? 1 : 0)};

transition: opacity 300ms;
`;
export const ImgSkeleton = styled.div`
width: 100%;
height: 100%;

background-image: linear-gradient(
135deg,
${({ theme }) => theme.colors.lightGray} 40%,
rgba(246, 246, 246, 0.89) 50%,
${({ theme }) => theme.colors.lightGray} 85%
);
background-size: 200% 100%;
border-radius: ${({ theme }) => theme.borderRadius.basic};

animation: skeleton-animation 1.5s infinite linear;

@keyframes skeleton-animation {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
`;
1 change: 1 addition & 0 deletions frontend/src/components/skeleton/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as ImgWithSkeleton } from './ImgWithSkeleton';
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ import { useRef, useState, useEffect } from 'react';

import nextArrowIcon from '@/assets/nextArrow.svg';
import prevArrowIcon from '@/assets/prevArrow.svg';
import { ImgWithSkeleton } from '@/components';
import { breakpoints } from '@/styles/theme';

import useSlideImgSize from '../../hooks/useSlideImgSize';

import * as S from './styles';

export interface Slide {
Expand Down Expand Up @@ -37,6 +40,7 @@ const InfinityCarousel = ({ slideList }: InfinityCarouselProps) => {
const [deltaX, setDeltaX] = useState(0); // 현재 드래그 중인 위치와 시작 위치 사이의 차이

const slideRef = useRef<HTMLDivElement>(null);
const { imgSize } = useSlideImgSize({ slideRef });

const slideLength = slideList.length;
// 첫 슬라이드와 마지막 슬라이드의 복제본을 각각 맨 뒤, 맨 처음에 추가
Expand Down Expand Up @@ -151,7 +155,9 @@ const InfinityCarousel = ({ slideList }: InfinityCarouselProps) => {
{clonedSlideList.map((slide, index) => (
<S.SlideItem key={index}>
<S.SlideContent>
<img src={slide.imageSrc} alt={slide.alt} />
<ImgWithSkeleton imgHeight={imgSize.height} imgWidth={imgSize.width}>
<S.SlideContentImg src={slide.imageSrc} alt={slide.alt} />
</ImgWithSkeleton>
</S.SlideContent>
</S.SlideItem>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,10 @@ export const SlideContent = styled.div`
justify-content: space-between;

width: 100%;
`;

img {
width: 80%;
}
export const SlideContentImg = styled.img`
width: 100%;
`;

export const PrevButton = styled.button`
Expand Down
42 changes: 42 additions & 0 deletions frontend/src/pages/HomePage/hooks/useSlideImgSize.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { useEffect, useState } from 'react';
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

구현 배경

홈 페이지의 경우, 이미지가 퍼센트로 설정되어 있고 768px이하에서 height가 설정되어 있지 않아서 이미지에 따라 자동으로 설정되는 상황이었어요. slideRef로 width를 받아올 수 있어서 캐러셀 이미지 비율을 사용해 height를 지정하는 방식을 사용했어요.


import { debounce } from '@/utils';

const DEBOUNCE_TIME = 300;

interface UseSlideImgSizeProps {
slideRef: React.RefObject<HTMLDivElement>;
}
const useSlideImgSize = ({ slideRef }: UseSlideImgSizeProps) => {
interface ImgSize {
width: string;
height: string;
}
const [imgSize, setImgSize] = useState<ImgSize>({ width: '', height: '' });

const updateImgSize = () => {
if (!slideRef.current) return;

const slideDomRect = slideRef.current.getBoundingClientRect();
const width = Math.ceil(slideDomRect.width * 0.8 * 0.1);
const height = width * 0.61;

setImgSize({ width: `${width}rem`, height: `${height}rem` });
};

const debouncedUpdateImgSize = debounce(updateImgSize, DEBOUNCE_TIME);

useEffect(() => {
updateImgSize();

document.addEventListener('resize', debouncedUpdateImgSize);

return () => {
document.removeEventListener('resize', debouncedUpdateImgSize);
};
}, [slideRef]);

return { imgSize };
};

export default useSlideImgSize;
27 changes: 12 additions & 15 deletions frontend/src/pages/ReviewZonePage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { useNavigate } from 'react-router';
import { useRecoilState } from 'recoil';

import ReviewZoneIcon from '@/assets/reviewZone.svg';
import { Button } from '@/components';
import { ROUTE } from '@/constants/route';
import { Button, ImgWithSkeleton } from '@/components';
import { ROUTE } from '@/constants';
import { useGetReviewGroupData, useSearchParamAndQuery, useModals } from '@/hooks';
import { reviewRequestCodeAtom } from '@/recoil';
import { calculateParticle } from '@/utils';
Expand All @@ -15,6 +15,11 @@ import * as S from './styles';
const MODAL_KEYS = {
content: 'CONTENT_MODAL',
};
const BUTTON_SIZE = {
width: '28rem',
height: '8.5rem',
};
const IMG_HEIGHT = '15rem';

const ReviewZonePage = () => {
const { isOpen, openModal, closeModal } = useModals();
Expand Down Expand Up @@ -46,29 +51,21 @@ const ReviewZonePage = () => {

return (
<S.ReviewZonePage>
<S.ReviewZoneMainImg src={ReviewZoneIcon} alt="" />
<ImgWithSkeleton imgWidth={BUTTON_SIZE.width} imgHeight={IMG_HEIGHT}>
<S.ReviewZoneMainImg src={ReviewZoneIcon} alt="" $height={IMG_HEIGHT} />
</ImgWithSkeleton>
<S.ReviewGuideContainer>
<S.ReviewGuide>{`${reviewGroupData.projectName}${calculateParticle({ target: reviewGroupData.projectName, particles: { withFinalConsonant: '을', withoutFinalConsonant: '를' } })} 함께한`}</S.ReviewGuide>
<S.ReviewGuide>{`${reviewGroupData.revieweeName}의 리뷰 공간이에요`}</S.ReviewGuide>
</S.ReviewGuideContainer>
<S.ButtonContainer>
<Button
styleType="primary"
type="button"
onClick={handleReviewWritingButtonClick}
style={{ width: '28rem', height: '8.5rem' }}
>
<Button styleType="primary" type="button" onClick={handleReviewWritingButtonClick} style={BUTTON_SIZE}>
<S.ButtonTextContainer>
<S.ButtonText>리뷰 쓰기</S.ButtonText>
<S.ButtonDescription>작성한 리뷰는 익명으로 제출돼요</S.ButtonDescription>
</S.ButtonTextContainer>
</Button>
<Button
styleType="secondary"
type="button"
onClick={handleReviewListButtonClick}
style={{ width: '28rem', height: '8.5rem' }}
>
<Button styleType="secondary" type="button" onClick={handleReviewListButtonClick} style={BUTTON_SIZE}>
<S.ButtonTextContainer>
<S.ButtonText>리뷰 확인하기</S.ButtonText>
<S.ButtonDescription>비밀번호로 내가 받은 리뷰를 확인할 수 있어요</S.ButtonDescription>
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/pages/ReviewZonePage/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ export const ReviewZonePage = styled.div`
justify-content: center;
`;

export const ReviewZoneMainImg = styled.img`
export const ReviewZoneMainImg = styled.img<{ $height: string }>`
width: 43rem;
height: 23rem;
height: ${(props) => props.$height};
`;

export const ReviewGuideContainer = styled.div`
Expand All @@ -25,6 +25,7 @@ export const ReviewGuideContainer = styled.div`
align-items: center;
justify-content: center;

margin-top: 3rem;
padding-left: 0.2rem;
`;

Expand Down
Loading