Files
web-page/src/pages/travel/Travel.jsx
gahusb d4ec482289 여행 탭 develop
- 지도 추가
 - 지도 매칭하여 해당 여행 사진 매칭
 - 사진 클릭시 모달로 사진 렌더링 (lazy, cache, thumb 사용)
2026-01-20 03:20:16 +09:00

524 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<>
<div className="travel-grid">
{visiblePhotos.map((photo, index) => {
const label = getPhotoLabel(photo);
return (
<article
key={`${regionLabel}-${photo.src}`}
className={`travel-card ${
index % 6 === 0 ? 'is-wide' : ''
}`}
onClick={(event) => onSelectPhoto(index, event)}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter') {
onSelectPhoto(index, event);
}
}}
>
<img
src={photo.src}
alt={label}
loading="lazy"
onError={(event) => {
const img = event.currentTarget;
if (photo.original && img.src !== photo.original) {
img.src = photo.original;
}
}}
/>
<div className="travel-card__overlay">
<p className="travel-card__title">{label}</p>
</div>
</article>
);
})}
</div>
<div className="travel-album__footer" ref={sentinelRef}>
{canLoadMore ? (
<button
type="button"
className="travel-load-more"
onClick={() =>
setVisibleCount((prev) =>
Math.min(prev + PHOTO_CHUNK_SIZE, photos.length)
)
}
>
Load more ({visibleCount}/{photos.length})
</button>
) : null}
</div>
</>
);
};
const RegionsLayer = ({ geojson, onSelectRegion }) => {
const map = useMap();
if (!geojson) return null;
return (
<GeoJSON
data={geojson}
style={() => ({
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 (
<div className="travel">
<header className="travel-header">
<div>
<p className="travel-kicker">Visual Diary</p>
<h1>Travel Archive</h1>
<p className="travel-sub">
여행에서 느낀 감성과 분위기를 모아 전시하는 페이지입니다.
</p>
</div>
<div className="travel-note">
<p className="travel-note__title">폴더별 큐레이션</p>
<p className="travel-note__desc">
지역마다 그리드와 기록의 흐름을 다르게 배치해 리듬감을 만들었습니다.
</p>
</div>
</header>
<section
className={`travel-albums ${
selectedPhotoIndex !== null ? 'is-blurred' : ''
}`}
>
<div className="travel-map">
<div className="travel-map__info">
<p className="travel-map__title">Select a region</p>
<p className="travel-map__desc">
{selectedRegion
? `${selectedRegion.name} 사진을 불러옵니다.`
: '지도에서 지역을 클릭하면 해당 사진만 로딩됩니다.'}
</p>
</div>
<MapContainer
center={[20, 0]}
zoom={2}
scrollWheelZoom
className="travel-map__canvas"
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>'
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
/>
<RegionsLayer
geojson={regionsGeojson}
onSelectRegion={setSelectedRegion}
/>
</MapContainer>
</div>
{loading ? (
<p className="travel-state">사진을 불러오는 ...</p>
) : null}
{error ? <p className="travel-error">{error}</p> : null}
{!loading && !error && selectedRegion && photos.length === 0 ? (
<p className="travel-state">
선택한 지역에 사진이 없습니다.
</p>
) : null}
{null}
</section>
{selectedPhotoIndex !== null ? (
<div
className="travel-modal"
role="dialog"
aria-modal="true"
onClick={() => setSelectedPhotoIndex(null)}
style={{ '--modal-offset': `${modalOffset}px` }}
>
<div
className="travel-modal__content"
onClick={(event) => event.stopPropagation()}
style={{ marginTop: `${modalOffset}px` }}
>
<div className="travel-modal__summary">
<p className="travel-modal__summary-title">
{selectedRegion?.name || 'Region'} ·{' '}
{photoSummary?.total ?? photos.length} photos
</p>
{photoSummary?.albums?.length ? (
<p className="travel-modal__summary-meta">
{photoSummary.albums
.map((album) => album.album)
.join(', ')}
</p>
) : null}
</div>
<button
type="button"
className="travel-modal__close"
onClick={() => setSelectedPhotoIndex(null)}
aria-label="Close"
>
×
</button>
<div className="travel-modal__nav">
<button
type="button"
className="travel-modal__arrow"
onClick={() =>
setSelectedPhotoIndex((prev) =>
prev === null
? prev
: (prev - 1 + photos.length) %
photos.length
)
}
aria-label="Previous"
>
{'<'}
</button>
<button
type="button"
className="travel-modal__arrow"
onClick={() =>
setSelectedPhotoIndex((prev) =>
prev === null
? prev
: (prev + 1) % photos.length
)
}
aria-label="Next"
>
{'>'}
</button>
</div>
<div className="travel-modal__strip" role="list">
{photos
.slice(stripStart, stripEnd)
.map((photo, idx) => {
const realIndex = stripStart + idx;
return (
<button
key={`${photo.src}-${realIndex}`}
type="button"
className={`travel-modal__thumb ${
realIndex === selectedPhotoIndex
? 'is-active'
: ''
}`}
onClick={() =>
setSelectedPhotoIndex(realIndex)
}
aria-label={getPhotoLabel(photo)}
role="listitem"
>
<img
src={photo.src}
alt={getPhotoLabel(photo)}
loading="lazy"
onError={(event) => {
const img = event.currentTarget;
if (
photo.original &&
img.src !== photo.original
) {
img.src = photo.original;
}
}}
/>
</button>
);
})}
</div>
<img
src={
photos[selectedPhotoIndex]?.original ||
photos[selectedPhotoIndex]?.src
}
alt={getPhotoLabel(photos[selectedPhotoIndex])}
/>
{photos[selectedPhotoIndex]?.album ||
photos[selectedPhotoIndex]?.file ? (
<p className="travel-modal__meta">
{photos[selectedPhotoIndex]?.album}{' '}
{photos[selectedPhotoIndex]?.file
? `- ${photos[selectedPhotoIndex]?.file}`
: ''}
</p>
) : null}
</div>
</div>
) : null}
</div>
);
};
export default Travel;