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 PAGE_SIZE = 20;
const THUMB_STRIP_LIMIT = 36;
const normalizePhotos = (items = []) =>
items
.map((item) => {
if (typeof item === 'string') return { src: item, title: '' };
if (!item) return null;
return {
src: item.thumb || item.url || item.path || item.src || '',
title: item.title || item.name || item.file || '',
original: item.url || item.path || item.src || '',
file: item.file || '',
album: item.album || '',
};
})
.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;
if (photo.file) return photo.file;
if (!photo.src) return '';
const parts = photo.src.split('/');
return parts[parts.length - 1];
};
const getStripRange = (length, center) => {
if (length <= THUMB_STRIP_LIMIT) return [0, length];
const half = Math.floor(THUMB_STRIP_LIMIT / 2);
let start = Math.max(0, center - half);
let end = start + THUMB_STRIP_LIMIT;
if (end > length) {
end = length;
start = end - THUMB_STRIP_LIMIT;
}
return [start, end];
};
const TravelPhotoGrid = ({
photos,
regionLabel,
onSelectPhoto,
onLoadMore,
hasNext,
isLoadingMore,
}) => {
const sentinelRef = useRef(null);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel || !onLoadMore) return undefined;
const observer = new IntersectionObserver(
(entries) => {
if (!entries[0]?.isIntersecting) return;
if (isLoadingMore || !hasNext) return;
onLoadMore();
},
{ rootMargin: '240px' }
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasNext, isLoadingMore, onLoadMore]);
return (
<>
{photos.map((photo, index) => {
const label = getPhotoLabel(photo);
return (
onSelectPhoto(index, event)}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter') {
onSelectPhoto(index, event);
}
}}
>
{
const img = event.currentTarget;
if (photo.original && img.src !== photo.original) {
img.src = photo.original;
}
}}
/>
);
})}
{hasNext ? (
) : photos.length ? (
모든 사진을 불러왔습니다.
) : null}
>
);
};
const RegionsLayer = ({ geojson, onSelectRegion }) => {
const map = useMap();
if (!geojson) return null;
return (
({
color: '#7c7c7c',
weight: 1,
fillOpacity: 0.2,
})}
onEachFeature={(feature, layer) => {
layer.on('click', () => {
if (!feature?.properties?.id) return;
map.fitBounds(layer.getBounds(), {
padding: [40, 40],
animate: true,
});
onSelectRegion({
id: feature.properties.id,
name: feature.properties.name || feature.properties.id,
});
});
}}
/>
);
};
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 touchStartXRef = useRef(null);
const [slideDirection, setSlideDirection] = useState('next');
const [slideToken, setSlideToken] = useState(0);
const [page, setPage] = useState(1);
const [hasNext, setHasNext] = useState(true);
const cacheRef = useRef(new Map());
const cacheTtlMs = 10 * 60 * 1000;
useEffect(() => {
const controller = new AbortController();
const loadRegions = async () => {
try {
const regionRes = await fetch('/api/travel/regions', {
signal: controller.signal,
});
if (!regionRes.ok) {
throw new Error(
`지역 정보 로딩 실패 (${regionRes.status})`
);
}
const regionJson = await regionRes.json();
setRegionsGeojson(regionJson);
} catch (err) {
if (err?.name === 'AbortError') return;
setError(err?.message ?? String(err));
}
};
loadRegions();
return () => controller.abort();
}, []);
useEffect(() => {
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);
} else {
setSelectedPhotoIndex(null);
}
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) {
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,
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,
});
if (normalized.length > 0) {
setModalOffset(24);
setSelectedPhotoIndex(0);
} else {
setSelectedPhotoIndex(null);
}
} catch (err) {
if (err?.name === 'AbortError') return;
setError(err?.message ?? String(err));
setPhotos([]);
setPhotoSummary(null);
} finally {
setLoading(false);
}
};
loadRegionPhotos();
return () => controller.abort();
}, [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]);
const bumpSlide = (direction) => {
setSlideDirection(direction);
setSlideToken((prev) => prev + 1);
};
const goPrev = useCallback(() => {
if (selectedPhotoIndex === null || selectedPhotoIndex <= 0) return;
bumpSlide('prev');
setSelectedPhotoIndex(selectedPhotoIndex - 1);
}, [selectedPhotoIndex]);
const goNext = useCallback(() => {
if (
selectedPhotoIndex === null ||
selectedPhotoIndex >= photos.length - 1
) {
return;
}
bumpSlide('next');
setSelectedPhotoIndex(selectedPhotoIndex + 1);
}, [photos.length, selectedPhotoIndex]);
useEffect(() => {
if (selectedPhotoIndex === null) return undefined;
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
setSelectedPhotoIndex(null);
return;
}
if (event.key === 'ArrowLeft') {
goPrev();
}
if (event.key === 'ArrowRight') {
goNext();
}
};
const handleTouchStart = (event) => {
if (selectedPhotoIndex === null) return;
const touch = event.touches[0];
touchStartXRef.current = touch.clientX;
};
const handleTouchEnd = (event) => {
if (selectedPhotoIndex === null || touchStartXRef.current === null)
return;
const touch = event.changedTouches[0];
const deltaX = touch.clientX - touchStartXRef.current;
if (Math.abs(deltaX) > 50) { // 스와이프 거리 임계값
if (deltaX > 0) {
// 왼쪽으로 스와이프: 이전 사진
goPrev();
} else {
// 오른쪽으로 스와이프: 다음 사진
goNext();
}
}
touchStartXRef.current = null;
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('touchstart', handleTouchStart);
window.addEventListener('touchend', handleTouchEnd);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('touchstart', handleTouchStart);
window.removeEventListener('touchend', handleTouchEnd);
};
}, [goNext, goPrev, selectedPhotoIndex]);
useEffect(() => {
if (selectedPhotoIndex === null) return undefined;
const { overflow } = document.body.style;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = overflow;
};
}, [selectedPhotoIndex]);
const [stripStart, stripEnd] =
selectedPhotoIndex === null
? [0, 0]
: getStripRange(photos.length, selectedPhotoIndex);
const handleSelectPhoto = (index, event) => {
if (selectedPhotoIndex === null) {
bumpSlide('next');
} else if (index !== selectedPhotoIndex) {
bumpSlide(index > selectedPhotoIndex ? 'next' : 'prev');
}
if (event) {
const pointY =
typeof event.clientY === 'number'
? event.clientY
: event?.currentTarget?.getBoundingClientRect
? event.currentTarget.getBoundingClientRect().top
: null;
if (pointY !== null) {
const nextOffset = Math.min(
Math.max(pointY - 120, 16),
window.innerHeight - 200
);
setModalOffset(nextOffset);
} else {
setModalOffset(24);
}
} else {
setModalOffset(24);
}
setSelectedPhotoIndex(index);
};
return (
Select a region
{selectedRegion
? `${selectedRegion.name} 사진을 불러옵니다.`
: '지도에서 지역을 클릭하면 해당 사진만 로딩됩니다.'}
{loading ? (
사진을 불러오는 중...
) : null}
{error ? {error}
: null}
{!loading && !error && selectedRegion && photos.length === 0 ? (
선택한 지역에 사진이 없습니다.
) : null}
{!loading && !error && selectedRegion ? (
) : null}
{selectedPhotoIndex !== null ? (
setSelectedPhotoIndex(null)}
style={{ '--modal-offset': `${modalOffset}px` }}
>
event.stopPropagation()}
style={{ marginTop: `${modalOffset}px` }}
>
{selectedRegion?.name || 'Region'} ·{' '}
{photoSummary?.total ?? photos.length} photos
{photoSummary?.albums?.length ? (
{photoSummary.albums
.map((album) => album.album)
.join(', ')}
) : null}
![{getPhotoLabel(photos[selectedPhotoIndex])}]({)
{
const img = event.currentTarget;
const original =
photos[selectedPhotoIndex]?.original;
if (original && img.src !== original) {
img.src = original;
}
}}
/>
{photos
.slice(stripStart, stripEnd)
.map((photo, idx) => {
const realIndex = stripStart + idx;
return (
);
})}
{photos[selectedPhotoIndex]?.album ||
photos[selectedPhotoIndex]?.file ? (
{photos[selectedPhotoIndex]?.album}{' '}
{photos[selectedPhotoIndex]?.file
? `- ${photos[selectedPhotoIndex]?.file}`
: ''}
) : null}
) : null}
);
};
export default Travel;