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
51 changes: 0 additions & 51 deletions frontend/src/components/common/LazyImage/LazyImage.tsx

This file was deleted.

7 changes: 4 additions & 3 deletions frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,18 @@ import { useGetClubDetail } from '@/hooks/queries/club/useGetClubDetail';
import isInAppWebView from '@/utils/isInAppWebView';
import { PAGE_VIEW } from '@/constants/eventName';

const MobileWindowWidth = 500;

const ClubDetailPage = () => {
const { clubId } = useParams<{ clubId: string }>();
const { sectionRefs, scrollToSection } = useAutoScroll();
const [showHeader, setShowHeader] = useState(window.innerWidth > 500);
const [showHeader, setShowHeader] = useState(window.innerWidth > MobileWindowWidth);

const { data: clubDetail, error } = useGetClubDetail(clubId || '');

useEffect(() => {
const handleResize = () => {
setShowHeader(window.innerWidth > 500);
setShowHeader(window.innerWidth > MobileWindowWidth);
};

window.addEventListener('resize', handleResize);
Expand Down Expand Up @@ -68,7 +70,6 @@ const ClubDetailPage = () => {
feeds={clubDetail.feeds}
clubName={clubDetail.name}
/>
{/* <RecommendedClubs clubs={clubDetail.recommendClubs ?? []} /> */}
</Styled.PageContainer>
<Footer />
<ClubDetailFooter
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import styled from 'styled-components';

export const ImageContainer = styled.div<{ $isLoaded: boolean; $placeholder: string }>`
position: relative;
width: 100%;
height: 100%;
background-color: ${({ $isLoaded, $placeholder }) =>
$isLoaded ? 'transparent' : $placeholder};
transition: background-color 0.3s;
`;

export const StyledImage = styled.img<{ $isLoaded: boolean }>`
opacity: ${({ $isLoaded }) => ($isLoaded ? 1 : 0)};
transition: opacity 0.3s ease-in;
width: 100%;
height: 100%;
object-fit: cover;
`;
Original file line number Diff line number Diff line change
Expand Up @@ -19,61 +19,51 @@ class MockIntersectionObserver {
observe = mockObserve;
}

// window.IntersectionObserver를 MockIntersectionObserver로 대체
/**
* window.IntersectionObserver를 MockIntersectionObserver로 대체
*/
Object.defineProperty(window, 'IntersectionObserver', {
writable: true,
configurable: true,
value: MockIntersectionObserver,
});

describe('LazyImage 컴포넌트', () => {
describe('LazyImage 컴포넌트 테스트', () => {
const defaultProps = {
src: 'test-image.jpg',
alt: '테스트 이미지',
};

beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
});

afterEach(() => {
jest.useRealTimers();
});

it('초기에는 이미지가 로드되지 않고 플레이스홀더가 표시되어야 함', () => {
it('초기에는 이미지가 로드되지 않아야 한다', () => {
render(<LazyImage {...defaultProps} />);

const placeholder = screen.getByTestId('lazy-image-placeholder');
expect(placeholder).toBeInTheDocument();
expect(placeholder).toHaveStyle({ backgroundColor: '#f0f0f0' });

const img = screen.queryByRole('img');
expect(img).not.toBeInTheDocument();
});

/**
* @test 컴포넌트가 mount될 때 IntersectionObserver가 요소를 관찰해야 한다.
*/
it('IntersectionObserver가 요소를 관찰해야 ', () => {
it('IntersectionObserver가 요소를 관찰해야 한다', () => {
render(<LazyImage {...defaultProps} />);

expect(mockObserve).toHaveBeenCalled();
});

it('요소가 화면에 보일 때 이미지를 로드해야 ', async () => {
it('요소가 화면에 보일 때 이미지를 로드해야 한다', async () => {
render(<LazyImage {...defaultProps} />);

const [[callback]] = mockIntersectionObserver.mock.calls;
const entry = { isIntersecting: true };

act(() => {
callback([entry], {} as IntersectionObserver);
});

act(() => {
jest.advanceTimersByTime(200);
});

await waitFor(() => {
const img = screen.getByRole('img');
expect(img).toBeInTheDocument();
Expand All @@ -84,29 +74,27 @@ describe('LazyImage 컴포넌트', () => {
expect(mockDisconnect).toHaveBeenCalled();
});

it('onError prop이 호출되어야 ', async () => {
it('onError prop이 호출되어야 한다', async () => {
const onError = jest.fn();
render(<LazyImage {...defaultProps} onError={onError} />);

const [[callback]] = mockIntersectionObserver.mock.calls;
const entry = { isIntersecting: true };

act(() => {
callback([entry], {} as IntersectionObserver);
});

act(() => {
jest.advanceTimersByTime(200);
});

const img = screen.getByRole('img');
const img = await screen.findByRole('img');

act(() => {
img.dispatchEvent(new Event('error'));
});

expect(onError).toHaveBeenCalled();
});

it('컴포넌트가 언마운트될 때 IntersectionObserver가 해제되어야 ', () => {
it('컴포넌트가 언마운트될 때 IntersectionObserver가 해제되어야 한다', () => {
const { unmount } = render(<LazyImage {...defaultProps} />);

unmount();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { useEffect, useRef, useState } from 'react';
import * as Styled from './LazyImage.styles';

interface LazyImageProps {
src: string;
alt: string;
onError?: () => void;
placeholder?: string;
threshold?: number;
isEager?: boolean;
}

const PLACEHOLDER_COLOR = '#f0f0f0';

const LazyImage = ({
src,
alt,
onError,
placeholder = PLACEHOLDER_COLOR,
threshold = 0.01,
isEager = false,
}: LazyImageProps) => {
const [isVisible, setIsVisible] = useState(isEager);
const [isLoaded, setIsLoaded] = useState(false);
const rootRef = useRef<HTMLDivElement | null>(null);

useEffect(() => {
if (isEager) return;
if (!rootRef.current) return;

const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
},
{
threshold,
}
);

observer.observe(rootRef.current);

return () => observer.disconnect();
}, [threshold, isEager]);

return (
<Styled.ImageContainer ref={rootRef} $isLoaded={isLoaded} $placeholder={placeholder}>
{isVisible && (
<Styled.StyledImage
src={src}
alt={alt}
onError={onError}
onLoad={() => setIsLoaded(true)}
loading={isEager ? 'eager' : 'lazy'}
$isLoaded={isLoaded}
/>
)}
</Styled.ImageContainer>
);
};

export default LazyImage;
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import React from 'react';
import * as Styled from '../PhotoList.styles';
import LazyImage from '@/components/common/LazyImage/LazyImage';
import LazyImage from '@/pages/ClubDetailPage/components/PhotoList/LazyImage/LazyImage';

interface PhotoCardListProps {
photoUrls: string[];
Expand All @@ -9,6 +8,8 @@ interface PhotoCardListProps {
onImageError: (index: number) => void;
}

const IMAGE_EAGER_LOADING_COUNT = 4;

const PhotoCardList = ({
photoUrls,
imageErrors,
Expand All @@ -24,6 +25,7 @@ const PhotoCardList = ({
src={url}
alt={`활동 사진 ${index + 1}`}
onError={() => onImageError(index)}
isEager={index < IMAGE_EAGER_LOADING_COUNT}
/>
) : (
<Styled.NoImageContainer>이미지 준비중..</Styled.NoImageContainer>
Expand Down