diff --git a/src/pages/travel/Travel.jsx b/src/pages/travel/Travel.jsx
index d4bbef0..54ebb33 100644
--- a/src/pages/travel/Travel.jsx
+++ b/src/pages/travel/Travel.jsx
@@ -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 (
<>
- {visiblePhotos.map((photo, index) => {
+ {photos.map((photo, index) => {
const label = getPhotoLabel(photo);
return (
{
})}
- {canLoadMore ? (
+ {hasNext ? (
+ ) : photos.length ? (
+
모든 사진을 불러왔습니다.
) : null}
>
@@ -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 = () => {
) : null}
- {null}
+ {!loading && !error && selectedRegion ? (
+
+ ) : null}
{selectedPhotoIndex !== null ? (
{
};
export default Travel;
+