import React, { 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 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 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 }) => {
const [visibleCount, setVisibleCount] = useState(() =>
Math.min(PHOTO_CHUNK_SIZE, photos.length)
);
const sentinelRef = useRef(null);
useEffect(() => {
setVisibleCount(Math.min(PHOTO_CHUNK_SIZE, photos.length));
}, [photos.length]);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return undefined;
const observer = new IntersectionObserver(
(entries) => {
if (!entries[0]?.isIntersecting) return;
setVisibleCount((prev) =>
Math.min(prev + PHOTO_CHUNK_SIZE, photos.length)
);
},
{ rootMargin: '240px' }
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [photos.length]);
const visiblePhotos = photos.slice(0, visibleCount);
const canLoadMore = visibleCount < photos.length;
return (
<>
{visiblePhotos.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;
}
}}
/>
);
})}
{canLoadMore ? (
) : 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 [error, setError] = useState('');
const [selectedRegion, setSelectedRegion] = useState(null);
const [regionsGeojson, setRegionsGeojson] = useState(null);
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState(null);
const [modalOffset, setModalOffset] = useState(24);
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) 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);
if (cached.items.length > 0) {
setModalOffset(24);
setSelectedPhotoIndex(0);
} else {
setSelectedPhotoIndex(null);
}
return;
}
setLoading(true);
setError('');
try {
const photoRes = await fetch(
`/api/travel/photos?region=${encodeURIComponent(
selectedRegion.id
)}`,
{ 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 ?? {};
setPhotoSummary({
total: summarySource.total ?? items.length,
albums: summarySource.matched_albums ?? [],
});
const normalized = normalizePhotos(items);
cacheRef.current.set(selectedRegion.id, {
timestamp: Date.now(),
items: normalized,
});
setPhotos(normalized);
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]);
useEffect(() => {
setSelectedPhotoIndex(null);
setPhotoSummary(null);
}, [selectedRegion]);
useEffect(() => {
if (selectedPhotoIndex === null) return undefined;
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
setSelectedPhotoIndex(null);
return;
}
if (event.key === 'ArrowLeft') {
setSelectedPhotoIndex((prev) =>
prev === null
? prev
: (prev - 1 + photos.length) % photos.length
);
}
if (event.key === 'ArrowRight') {
setSelectedPhotoIndex((prev) =>
prev === null ? prev : (prev + 1) % photos.length
);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [photos.length, 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 (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}
{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}
{photos
.slice(stripStart, stripEnd)
.map((photo, idx) => {
const realIndex = stripStart + idx;
return (
);
})}
![{getPhotoLabel(photos[selectedPhotoIndex])}]({)
{photos[selectedPhotoIndex]?.album ||
photos[selectedPhotoIndex]?.file ? (
{photos[selectedPhotoIndex]?.album}{' '}
{photos[selectedPhotoIndex]?.file
? `- ${photos[selectedPhotoIndex]?.file}`
: ''}
) : null}
) : null}
);
};
export default Travel;