From 2495feef3e4b670aa068d088eb70108bb5634b80 Mon Sep 17 00:00:00 2001
From: gahusb
Date: Sun, 25 Jan 2026 19:43:34 +0900
Subject: [PATCH] =?UTF-8?q?=EC=97=AC=ED=96=89=20=EA=B8=B0=EB=A1=9D=20?=
=?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94,=20=ED=8E=98=EC=9D=B4=EC=A7=80?=
=?UTF-8?q?=EB=84=A4=EC=9D=B4=EC=85=98,=20lazy=20=EB=A1=9C=EB=94=A9=20?=
=?UTF-8?q?=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/pages/travel/Travel.jsx | 181 ++++++++++++++++++++++++++++--------
1 file changed, 143 insertions(+), 38 deletions(-)
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;
+