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); } }} > {label} { const img = event.currentTarget; if (photo.original && img.src !== photo.original) { img.src = photo.original; } }} />

{label}

); })}
{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 (

Visual Diary

Travel Archive

여행에서 느낀 감성과 분위기를 모아 전시하는 페이지입니다.

폴더별 큐레이션

지역마다 그리드와 기록의 흐름을 다르게 배치해 리듬감을 만들었습니다.

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;