여행 기록 최적화, 페이지네이션, lazy 로딩 추가
This commit is contained in:
@@ -1,9 +1,9 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { GeoJSON, MapContainer, TileLayer, useMap } from 'react-leaflet';
|
import { GeoJSON, MapContainer, TileLayer, useMap } from 'react-leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import './Travel.css';
|
import './Travel.css';
|
||||||
|
|
||||||
const PHOTO_CHUNK_SIZE = 60;
|
const PAGE_SIZE = 20;
|
||||||
const THUMB_STRIP_LIMIT = 36;
|
const THUMB_STRIP_LIMIT = 36;
|
||||||
|
|
||||||
const normalizePhotos = (items = []) =>
|
const normalizePhotos = (items = []) =>
|
||||||
@@ -21,6 +21,11 @@ const normalizePhotos = (items = []) =>
|
|||||||
})
|
})
|
||||||
.filter((item) => item && item.src);
|
.filter((item) => item && item.src);
|
||||||
|
|
||||||
|
const hasSummaryInfo = (payload) =>
|
||||||
|
payload &&
|
||||||
|
(Object.prototype.hasOwnProperty.call(payload, 'total') ||
|
||||||
|
Object.prototype.hasOwnProperty.call(payload, 'matched_albums'));
|
||||||
|
|
||||||
const getPhotoLabel = (photo) => {
|
const getPhotoLabel = (photo) => {
|
||||||
if (!photo) return '';
|
if (!photo) return '';
|
||||||
if (photo.title) return photo.title;
|
if (photo.title) return photo.title;
|
||||||
@@ -42,41 +47,37 @@ const getStripRange = (length, center) => {
|
|||||||
return [start, end];
|
return [start, end];
|
||||||
};
|
};
|
||||||
|
|
||||||
const TravelPhotoGrid = ({ photos, regionLabel, onSelectPhoto }) => {
|
const TravelPhotoGrid = ({
|
||||||
const [visibleCount, setVisibleCount] = useState(() =>
|
photos,
|
||||||
Math.min(PHOTO_CHUNK_SIZE, photos.length)
|
regionLabel,
|
||||||
);
|
onSelectPhoto,
|
||||||
|
onLoadMore,
|
||||||
|
hasNext,
|
||||||
|
isLoadingMore,
|
||||||
|
}) => {
|
||||||
const sentinelRef = useRef(null);
|
const sentinelRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setVisibleCount(Math.min(PHOTO_CHUNK_SIZE, photos.length));
|
|
||||||
}, [photos.length]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sentinel = sentinelRef.current;
|
const sentinel = sentinelRef.current;
|
||||||
if (!sentinel) return undefined;
|
if (!sentinel || !onLoadMore) return undefined;
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
if (!entries[0]?.isIntersecting) return;
|
if (!entries[0]?.isIntersecting) return;
|
||||||
setVisibleCount((prev) =>
|
if (isLoadingMore || !hasNext) return;
|
||||||
Math.min(prev + PHOTO_CHUNK_SIZE, photos.length)
|
onLoadMore();
|
||||||
);
|
|
||||||
},
|
},
|
||||||
{ rootMargin: '240px' }
|
{ rootMargin: '240px' }
|
||||||
);
|
);
|
||||||
|
|
||||||
observer.observe(sentinel);
|
observer.observe(sentinel);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [photos.length]);
|
}, [hasNext, isLoadingMore, onLoadMore]);
|
||||||
|
|
||||||
const visiblePhotos = photos.slice(0, visibleCount);
|
|
||||||
const canLoadMore = visibleCount < photos.length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="travel-grid">
|
<div className="travel-grid">
|
||||||
{visiblePhotos.map((photo, index) => {
|
{photos.map((photo, index) => {
|
||||||
const label = getPhotoLabel(photo);
|
const label = getPhotoLabel(photo);
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
@@ -112,18 +113,19 @@ const TravelPhotoGrid = ({ photos, regionLabel, onSelectPhoto }) => {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="travel-album__footer" ref={sentinelRef}>
|
<div className="travel-album__footer" ref={sentinelRef}>
|
||||||
{canLoadMore ? (
|
{hasNext ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="travel-load-more"
|
className="travel-load-more"
|
||||||
onClick={() =>
|
onClick={onLoadMore}
|
||||||
setVisibleCount((prev) =>
|
disabled={isLoadingMore}
|
||||||
Math.min(prev + PHOTO_CHUNK_SIZE, photos.length)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Load more ({visibleCount}/{photos.length})
|
{isLoadingMore
|
||||||
|
? 'Loading more...'
|
||||||
|
: `Load more (${photos.length})`}
|
||||||
</button>
|
</button>
|
||||||
|
) : photos.length ? (
|
||||||
|
<p className="travel-load-more">모든 사진을 불러왔습니다.</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -164,12 +166,15 @@ const Travel = () => {
|
|||||||
const [photos, setPhotos] = useState([]);
|
const [photos, setPhotos] = useState([]);
|
||||||
const [photoSummary, setPhotoSummary] = useState(null);
|
const [photoSummary, setPhotoSummary] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [selectedRegion, setSelectedRegion] = useState(null);
|
const [selectedRegion, setSelectedRegion] = useState(null);
|
||||||
const [regionsGeojson, setRegionsGeojson] = useState(null);
|
const [regionsGeojson, setRegionsGeojson] = useState(null);
|
||||||
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState(null);
|
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState(null);
|
||||||
const [modalOffset, setModalOffset] = useState(24);
|
const [modalOffset, setModalOffset] = useState(24);
|
||||||
const [touchStartX, setTouchStartX] = useState(null);
|
const [touchStartX, setTouchStartX] = useState(null);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [hasNext, setHasNext] = useState(true);
|
||||||
const cacheRef = useRef(new Map());
|
const cacheRef = useRef(new Map());
|
||||||
const cacheTtlMs = 10 * 60 * 1000;
|
const cacheTtlMs = 10 * 60 * 1000;
|
||||||
|
|
||||||
@@ -199,13 +204,26 @@ const Travel = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedRegion) return undefined;
|
if (!selectedRegion) {
|
||||||
|
setPhotos([]);
|
||||||
|
setPhotoSummary(null);
|
||||||
|
setSelectedPhotoIndex(null);
|
||||||
|
setPage(1);
|
||||||
|
setHasNext(true);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
const loadRegionPhotos = async () => {
|
const loadRegionPhotos = async () => {
|
||||||
const cached = cacheRef.current.get(selectedRegion.id);
|
const cached = cacheRef.current.get(selectedRegion.id);
|
||||||
if (cached && Date.now() - cached.timestamp < cacheTtlMs) {
|
if (cached && Date.now() - cached.timestamp < cacheTtlMs) {
|
||||||
setPhotos(cached.items);
|
setPhotos(cached.items);
|
||||||
|
setPhotoSummary(cached.summary ?? null);
|
||||||
|
setPage(cached.page ?? 1);
|
||||||
|
setHasNext(cached.hasNext ?? true);
|
||||||
|
setLoading(false);
|
||||||
|
setLoadingMore(false);
|
||||||
|
setError('');
|
||||||
if (cached.items.length > 0) {
|
if (cached.items.length > 0) {
|
||||||
setModalOffset(24);
|
setModalOffset(24);
|
||||||
setSelectedPhotoIndex(0);
|
setSelectedPhotoIndex(0);
|
||||||
@@ -215,13 +233,19 @@ const Travel = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setLoadingMore(false);
|
||||||
setError('');
|
setError('');
|
||||||
|
setPhotos([]);
|
||||||
|
setPhotoSummary(null);
|
||||||
|
setSelectedPhotoIndex(null);
|
||||||
|
setPage(1);
|
||||||
|
setHasNext(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const photoRes = await fetch(
|
const photoRes = await fetch(
|
||||||
`/api/travel/photos?region=${encodeURIComponent(
|
`/api/travel/photos?region=${encodeURIComponent(
|
||||||
selectedRegion.id
|
selectedRegion.id
|
||||||
)}`,
|
)}&page=1&size=${PAGE_SIZE}`,
|
||||||
{ signal: controller.signal }
|
{ signal: controller.signal }
|
||||||
);
|
);
|
||||||
if (!photoRes.ok) {
|
if (!photoRes.ok) {
|
||||||
@@ -236,16 +260,30 @@ const Travel = () => {
|
|||||||
const summarySource = Array.isArray(photoJson)
|
const summarySource = Array.isArray(photoJson)
|
||||||
? {}
|
? {}
|
||||||
: photoJson ?? {};
|
: photoJson ?? {};
|
||||||
setPhotoSummary({
|
|
||||||
total: summarySource.total ?? items.length,
|
|
||||||
albums: summarySource.matched_albums ?? [],
|
|
||||||
});
|
|
||||||
const normalized = normalizePhotos(items);
|
const normalized = normalizePhotos(items);
|
||||||
|
const nextHasNext =
|
||||||
|
typeof summarySource.has_next === 'boolean'
|
||||||
|
? summarySource.has_next
|
||||||
|
: typeof summarySource.hasNext === 'boolean'
|
||||||
|
? summarySource.hasNext
|
||||||
|
: normalized.length >= PAGE_SIZE;
|
||||||
|
const summaryPayload = hasSummaryInfo(summarySource)
|
||||||
|
? {
|
||||||
|
total: summarySource.total,
|
||||||
|
albums: summarySource.matched_albums ?? [],
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
setPhotoSummary(summaryPayload);
|
||||||
|
setPhotos(normalized);
|
||||||
|
setHasNext(nextHasNext);
|
||||||
|
setPage(2);
|
||||||
cacheRef.current.set(selectedRegion.id, {
|
cacheRef.current.set(selectedRegion.id, {
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
items: normalized,
|
items: normalized,
|
||||||
|
page: 2,
|
||||||
|
hasNext: nextHasNext,
|
||||||
|
summary: summaryPayload,
|
||||||
});
|
});
|
||||||
setPhotos(normalized);
|
|
||||||
if (normalized.length > 0) {
|
if (normalized.length > 0) {
|
||||||
setModalOffset(24);
|
setModalOffset(24);
|
||||||
setSelectedPhotoIndex(0);
|
setSelectedPhotoIndex(0);
|
||||||
@@ -266,10 +304,67 @@ const Travel = () => {
|
|||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [selectedRegion]);
|
}, [selectedRegion]);
|
||||||
|
|
||||||
useEffect(() => {
|
const loadMorePhotos = useCallback(async () => {
|
||||||
setSelectedPhotoIndex(null);
|
if (!selectedRegion || loading || loadingMore || !hasNext) return;
|
||||||
setPhotoSummary(null);
|
setLoadingMore(true);
|
||||||
}, [selectedRegion]);
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const photoRes = await fetch(
|
||||||
|
`/api/travel/photos?region=${encodeURIComponent(
|
||||||
|
selectedRegion.id
|
||||||
|
)}&page=${page}&size=${PAGE_SIZE}`
|
||||||
|
);
|
||||||
|
if (!photoRes.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`지역 사진 로딩 실패 (${photoRes.status})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const photoJson = await photoRes.json();
|
||||||
|
const items = Array.isArray(photoJson)
|
||||||
|
? photoJson
|
||||||
|
: photoJson.items ?? [];
|
||||||
|
const summarySource = Array.isArray(photoJson)
|
||||||
|
? {}
|
||||||
|
: photoJson ?? {};
|
||||||
|
const normalized = normalizePhotos(items);
|
||||||
|
const nextHasNext =
|
||||||
|
typeof summarySource.has_next === 'boolean'
|
||||||
|
? summarySource.has_next
|
||||||
|
: typeof summarySource.hasNext === 'boolean'
|
||||||
|
? summarySource.hasNext
|
||||||
|
: normalized.length >= PAGE_SIZE;
|
||||||
|
const summaryPayload = hasSummaryInfo(summarySource)
|
||||||
|
? {
|
||||||
|
total: summarySource.total ?? photoSummary?.total,
|
||||||
|
albums:
|
||||||
|
summarySource.matched_albums ??
|
||||||
|
photoSummary?.albums ??
|
||||||
|
[],
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
setPhotos((prev) => {
|
||||||
|
const merged = [...prev, ...normalized];
|
||||||
|
cacheRef.current.set(selectedRegion.id, {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
items: merged,
|
||||||
|
page: page + 1,
|
||||||
|
hasNext: nextHasNext,
|
||||||
|
summary: photoSummary ?? summaryPayload,
|
||||||
|
});
|
||||||
|
return merged;
|
||||||
|
});
|
||||||
|
if (!photoSummary && summaryPayload) {
|
||||||
|
setPhotoSummary(summaryPayload);
|
||||||
|
}
|
||||||
|
setHasNext(nextHasNext);
|
||||||
|
setPage((prev) => prev + 1);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err?.message ?? String(err));
|
||||||
|
} finally {
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
}, [hasNext, loading, loadingMore, page, photoSummary, selectedRegion]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedPhotoIndex === null) return undefined;
|
if (selectedPhotoIndex === null) return undefined;
|
||||||
@@ -427,7 +522,16 @@ const Travel = () => {
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{null}
|
{!loading && !error && selectedRegion ? (
|
||||||
|
<TravelPhotoGrid
|
||||||
|
photos={photos}
|
||||||
|
regionLabel={selectedRegion.id}
|
||||||
|
onSelectPhoto={handleSelectPhoto}
|
||||||
|
onLoadMore={loadMorePhotos}
|
||||||
|
hasNext={hasNext}
|
||||||
|
isLoadingMore={loadingMore}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
{selectedPhotoIndex !== null ? (
|
{selectedPhotoIndex !== null ? (
|
||||||
<div
|
<div
|
||||||
@@ -556,3 +660,4 @@ const Travel = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default Travel;
|
export default Travel;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user