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