Skip to content

Commit e43b3ac

Browse files
authored
fix: improve image loading performance (#56)
1 parent 9191f6e commit e43b3ac

File tree

7 files changed

+159
-68
lines changed

7 files changed

+159
-68
lines changed

public/changelog.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"Reorder entries in the filters box.",
77
"Fix release date ordering button.",
88
"Add stamps for 1st edition and reverse holo cards.",
9-
"Fix cards with non numeric card numbers not showing up in the grid."
9+
"Fix cards with non numeric card numbers not showing up in the grid.",
10+
"Fix cards not loading properly when there are too many on the screen."
1011
]
1112
},
1213
{

src/App.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import FilterBox from './components/FilterBox';
1818

1919
function App() {
2020
const [query, setQuery] = useState('');
21-
const { images, setImages, loading, handleSearch } = useCardSearch();
21+
const { images, setImages, loading, loadingMore, hasMore, handleSearch, loadMore } = useCardSearch();
2222
const {
2323
showScrollButton,
2424
isChangelogOpen,
@@ -214,6 +214,9 @@ function App() {
214214
query={query}
215215
showReverseHolos={showReverseHolos}
216216
searchPerformed={searchPerformed}
217+
loadMore={loadMore}
218+
hasMore={hasMore}
219+
loadingMore={loadingMore}
217220
/>
218221
{images.length > 0 && (
219222
<>

src/components/ImageGrid.tsx

Lines changed: 51 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import React from 'react';
1+
import React, { useRef, useCallback } from 'react';
22
import type { CardImage } from '../lib/types';
33
import { R2_BUCKET_URL } from '../lib/constants';
44
import { capitalizeWords } from '../lib/utils';
5-
import LazyImage from './LazyImage'; // Import the LazyImage component
5+
import LazyImage from './LazyImage';
66

77
interface ImageGridProps {
88
loading: boolean;
@@ -13,6 +13,9 @@ interface ImageGridProps {
1313
query: string;
1414
showReverseHolos: boolean;
1515
searchPerformed: boolean;
16+
loadMore: () => void;
17+
hasMore: boolean;
18+
loadingMore: boolean;
1619
}
1720

1821
const ImageGrid: React.FC<ImageGridProps> = ({
@@ -24,7 +27,22 @@ const ImageGrid: React.FC<ImageGridProps> = ({
2427
query,
2528
showReverseHolos,
2629
searchPerformed,
30+
loadMore,
31+
hasMore,
32+
loadingMore,
2733
}) => {
34+
const observer = useRef<IntersectionObserver>();
35+
const lastImageElementRef = useCallback(node => {
36+
if (loading || loadingMore) return;
37+
if (observer.current) observer.current.disconnect();
38+
observer.current = new IntersectionObserver(entries => {
39+
if (entries[0].isIntersecting && hasMore) {
40+
loadMore();
41+
}
42+
});
43+
if (node) observer.current.observe(node);
44+
}, [loading, loadingMore, hasMore, loadMore]);
45+
2846
const filteredImages = showReverseHolos ? images : images.filter(image => image.isReverseHolo !== 1);
2947

3048
if (loading) {
@@ -44,31 +62,38 @@ const ImageGrid: React.FC<ImageGridProps> = ({
4462
}
4563

4664
return (
47-
<div className="image-grid" style={{ gridTemplateColumns: `repeat(${gridCols}, 1fr)` }}>
48-
{filteredImages.map((image) => (
49-
<div key={image.imageKey} className="image-card">
50-
<LazyImage
51-
src={`${R2_BUCKET_URL}/${image.imageKey}`}
52-
alt={image.cardTitle}
53-
className="grid-image"
54-
onClick={() => openModal(image)}
55-
/>
56-
<div className="badge-container">
57-
{image.tags.includes('1st-edition') && (
58-
<div className="first-edition-badge">1st</div>
59-
)}
60-
{image.isReverseHolo === 1 && (
61-
<div className="reverse-holo-badge">RH</div>
62-
)}
63-
</div>
64-
{showSetNames && (
65-
<div className="set-name-overlay">
66-
<span>{capitalizeWords(image.setName)}</span>
65+
<>
66+
<div className="image-grid" style={{ gridTemplateColumns: `repeat(${gridCols}, 1fr)` }}>
67+
{filteredImages.map((image, index) => {
68+
const isLastElement = index === filteredImages.length - 1;
69+
return (
70+
<div key={image.imageKey} ref={isLastElement ? lastImageElementRef : null} className="image-card">
71+
<LazyImage
72+
src={`${R2_BUCKET_URL}/${image.imageKey}`}
73+
alt={image.cardTitle}
74+
className="grid-image"
75+
onClick={() => openModal(image)}
76+
style={{ animationDelay: `${index * 50}ms` }}
77+
/>
78+
<div className="badge-container">
79+
{image.tags.includes('1st-edition') && (
80+
<div className="first-edition-badge">1st</div>
81+
)}
82+
{image.isReverseHolo === 1 && (
83+
<div className="reverse-holo-badge">RH</div>
84+
)}
85+
</div>
86+
{showSetNames && (
87+
<div className="set-name-overlay">
88+
<span>{capitalizeWords(image.setName)}</span>
89+
</div>
90+
)}
6791
</div>
68-
)}
69-
</div>
70-
))}
71-
</div>
92+
);
93+
})}
94+
</div>
95+
{loadingMore && <div className="loading-spinner"></div>}
96+
</>
7297
);
7398
};
7499

src/components/LazyImage.tsx

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@ interface LazyImageProps {
55
alt: string;
66
className?: string;
77
onClick: () => void;
8+
style?: React.CSSProperties;
89
}
910

10-
const LazyImage: React.FC<LazyImageProps> = ({ src, alt, className, onClick }) => {
11+
const LazyImage: React.FC<LazyImageProps> = ({ src, alt, className, onClick, style }) => {
1112
const [isLoaded, setIsLoaded] = useState(false);
1213
const [isInView, setIsInView] = useState(false);
13-
const imgRef = useRef<HTMLImageElement | null>(null);
14+
const containerRef = useRef<HTMLDivElement | null>(null);
1415

1516
useEffect(() => {
1617
const observer = new IntersectionObserver(
@@ -21,33 +22,34 @@ const LazyImage: React.FC<LazyImageProps> = ({ src, alt, className, onClick }) =
2122
}
2223
},
2324
{
24-
rootMargin: '100px', // Load images 100px before they enter the viewport
25+
rootMargin: '200px',
2526
}
2627
);
2728

28-
if (imgRef.current) {
29-
observer.observe(imgRef.current);
29+
if (containerRef.current) {
30+
observer.observe(containerRef.current);
3031
}
3132

3233
return () => {
33-
if (imgRef.current) {
34-
observer.unobserve(imgRef.current);
34+
if (containerRef.current) {
35+
observer.unobserve(containerRef.current);
3536
}
3637
};
3738
}, []);
3839

39-
const handleImageLoad = () => {
40-
setIsLoaded(true);
41-
};
42-
4340
return (
44-
<div className={`lazy-image-container ${isLoaded ? 'loaded' : ''}`} ref={imgRef} onClick={onClick}>
41+
<div
42+
className={`lazy-image-container ${isLoaded ? 'loaded' : ''}`}
43+
ref={containerRef}
44+
onClick={onClick}
45+
style={style}
46+
>
4547
{isInView && (
4648
<img
4749
src={src}
4850
alt={alt}
4951
className={className}
50-
onLoad={handleImageLoad}
52+
onLoad={() => setIsLoaded(true)}
5153
/>
5254
)}
5355
</div>

src/hooks/useCardSearch.ts

Lines changed: 59 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,78 @@
1-
import { useState } from 'react';
1+
import { useState, useCallback } from 'react';
22
import type { CardImage } from '../lib/types';
33
import { API_ENDPOINT } from '../lib/constants';
44

5+
const PAGE_SIZE_INITIAL = 30;
6+
const PAGE_SIZE_MORE = 20;
7+
58
export const useCardSearch = () => {
69
const [images, setImages] = useState<CardImage[]>([]);
710
const [loading, setLoading] = useState(false);
11+
const [loadingMore, setLoadingMore] = useState(false);
12+
const [page, setPage] = useState(1);
13+
const [hasMore, setHasMore] = useState(false);
14+
const [currentQuery, setCurrentQuery] = useState({});
815

9-
const handleSearch = async (
10-
query: string,
11-
isCameo: boolean,
12-
isTrainer: boolean,
13-
isIllustrator: boolean,
14-
sortOrder: 'asc' | 'desc',
15-
isSet: boolean
16-
) => {
17-
if (!query.trim()) return;
18-
setLoading(true);
19-
setImages([]);
20-
const processedQuery = query.split(',').map(part => part.trim().toLowerCase().replace(/\s+/g, '-')).join(',');
21-
const params = new URLSearchParams({ q: processedQuery });
22-
if (isCameo) params.append('cameo', '1');
23-
if (isTrainer) params.append('trainer', '1');
24-
if (isIllustrator) params.append('illustrator', '1');
25-
if (sortOrder === 'desc') params.append('descending', '1');
26-
if (isSet) params.append('set', '1');
16+
const fetchImages = useCallback(async (searchParams: any, isNewSearch: boolean) => {
17+
if (isNewSearch) {
18+
setLoading(true);
19+
setImages([]);
20+
} else {
21+
setLoadingMore(true);
22+
}
23+
24+
const params = new URLSearchParams(searchParams);
2725
try {
2826
const response = await fetch(`${API_ENDPOINT}?${params.toString()}`);
2927
if (!response.ok) throw new Error(`Network response was not ok: ${response.statusText}`);
3028
const data = await response.json();
31-
setImages(data.image_rows || []);
29+
const newImages = data.image_rows || [];
30+
31+
setImages(prev => isNewSearch ? newImages : [...prev, ...newImages]);
32+
setHasMore(newImages.length === (isNewSearch ? PAGE_SIZE_INITIAL : PAGE_SIZE_MORE));
3233
} catch (error) {
3334
console.error("Error fetching images:", error);
3435
alert("Failed to fetch images. Check console for details.");
3536
} finally {
3637
setLoading(false);
38+
setLoadingMore(false);
3739
}
38-
};
40+
}, []);
41+
42+
const handleSearch = useCallback((query: string, isCameo: boolean, isTrainer: boolean, isIllustrator: boolean, sortOrder: 'asc' | 'desc', isSet: boolean) => {
43+
if (!query.trim()) return;
44+
45+
const newQuery = {
46+
q: query.split(',').map(part => part.trim().toLowerCase().replace(/\s+/g, '-')).join(','),
47+
limit: PAGE_SIZE_INITIAL,
48+
offset: 0,
49+
...(isCameo && { cameo: '1' }),
50+
...(isTrainer && { trainer: '1' }),
51+
...(isIllustrator && { illustrator: '1' }),
52+
...(sortOrder === 'desc' && { descending: '1' }),
53+
...(isSet && { set: '1' }),
54+
};
55+
56+
setCurrentQuery(newQuery);
57+
setPage(1);
58+
fetchImages(newQuery, true);
59+
}, [fetchImages]);
60+
61+
const loadMore = useCallback(() => {
62+
if (loadingMore || !hasMore) return;
63+
64+
const nextPage = page + 1;
65+
const newOffset = (page * PAGE_SIZE_MORE) + (PAGE_SIZE_INITIAL - PAGE_SIZE_MORE);
66+
67+
const newQuery = {
68+
...currentQuery,
69+
limit: PAGE_SIZE_MORE,
70+
offset: newOffset,
71+
};
72+
73+
setPage(nextPage);
74+
fetchImages(newQuery, false);
75+
}, [loadingMore, hasMore, page, currentQuery, fetchImages]);
3976

40-
return { images, setImages, loading, handleSearch };
77+
return { images, setImages, loading, loadingMore, hasMore, handleSearch, loadMore };
4178
};

src/styles/LazyImage.css

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,33 @@
1+
@keyframes fadeIn {
2+
from {
3+
opacity: 0;
4+
transform: translateY(10px);
5+
}
6+
to {
7+
opacity: 1;
8+
transform: translateY(0);
9+
}
10+
}
11+
112
.lazy-image-container {
2-
background-color: #2d2d2d; /* A placeholder background color */
3-
transition: background-color 0.3s;
4-
min-height: 200px; /* Or a more appropriate height */
13+
background-color: #2d2d2d;
14+
min-height: 200px;
515
display: flex;
616
align-items: center;
717
justify-content: center;
818
border-radius: 8px;
19+
opacity: 0;
20+
animation: fadeIn 0.5s forwards;
921
}
1022

1123
.lazy-image-container.loaded {
1224
background-color: transparent;
1325
}
1426

27+
.lazy-image-container.error {
28+
background-color: #222; /* Darker background for error state */
29+
}
30+
1531
.lazy-image-container img {
1632
opacity: 0;
1733
transition: opacity 0.5s;
@@ -20,3 +36,9 @@
2036
.lazy-image-container.loaded img {
2137
opacity: 1;
2238
}
39+
40+
.image-error-placeholder {
41+
color: #555;
42+
font-size: 50px;
43+
font-weight: bold;
44+
}

src/styles/Modal.css

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,9 @@
2929
}
3030

3131
.modal-image {
32-
height: 80vh; /* Fixed height */
33-
max-width: 60vw;
32+
width: auto;
33+
height: 80vh;
34+
aspect-ratio: 1 / 1.4;
3435
object-fit: contain;
3536
border-radius: 8px;
3637
flex-shrink: 0; /* Prevent image from shrinking */

0 commit comments

Comments
 (0)