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

{label}

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

Visual Diary

Travel Archive

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

폴더별 큐레이션

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

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;