여행 기록 최적화, 페이지네이션, lazy 로딩 추가

This commit is contained in:
2026-01-25 19:43:34 +09:00
parent 57133a38db
commit 2495feef3e

View File

@@ -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;