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