여행 탭 develop
- 지도 추가 - 지도 매칭하여 해당 여행 사진 매칭 - 사진 클릭시 모달로 사진 렌더링 (lazy, cache, thumb 사용)
This commit is contained in:
@@ -1,84 +1,337 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
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.url || item.path || item.src || '',
|
||||
title: item.title || item.name || '',
|
||||
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 = (src) => {
|
||||
if (!src) return '';
|
||||
const parts = src.split('/');
|
||||
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 Travel = () => {
|
||||
const [albums, setAlbums] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
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(() => {
|
||||
let cancelled = false;
|
||||
setVisibleCount(Math.min(PHOTO_CHUNK_SIZE, photos.length));
|
||||
}, [photos.length]);
|
||||
|
||||
const loadAlbums = async () => {
|
||||
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 albumRes = await fetch('/api/travel/albums');
|
||||
if (!albumRes.ok) {
|
||||
throw new Error(`앨범 목록 로딩 실패 (${albumRes.status})`);
|
||||
}
|
||||
const albumJson = await albumRes.json();
|
||||
const items = albumJson.items ?? [];
|
||||
|
||||
const hydrated = await Promise.all(
|
||||
items.map(async (item) => {
|
||||
const name = item.album || item.name || '';
|
||||
if (!name) return null;
|
||||
const photoRes = await fetch(
|
||||
`/api/travel/albums/${encodeURIComponent(name)}`
|
||||
);
|
||||
if (!photoRes.ok) {
|
||||
throw new Error(`앨범 로딩 실패: ${name}`);
|
||||
}
|
||||
const photoJson = await photoRes.json();
|
||||
const photos = normalizePhotos(photoJson.items ?? []);
|
||||
return {
|
||||
name,
|
||||
count: item.count ?? photos.length,
|
||||
cover: item.cover || photos[0]?.src || '',
|
||||
photos,
|
||||
};
|
||||
})
|
||||
const photoRes = await fetch(
|
||||
`/api/travel/photos?region=${encodeURIComponent(
|
||||
selectedRegion.id
|
||||
)}`,
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
|
||||
if (!cancelled) {
|
||||
setAlbums(hydrated.filter(Boolean));
|
||||
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 (!cancelled) {
|
||||
setError(err?.message ?? String(err));
|
||||
}
|
||||
if (err?.name === 'AbortError') return;
|
||||
setError(err?.message ?? String(err));
|
||||
setPhotos([]);
|
||||
setPhotoSummary(null);
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadAlbums();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
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">
|
||||
@@ -87,64 +340,182 @@ const Travel = () => {
|
||||
<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__title">폴더별 큐레이션</p>
|
||||
<p className="travel-note__desc">
|
||||
사진마다 그리드 크기를 다르게 배치해 리듬을 만들었습니다.
|
||||
지역마다 그리드와 기록의 흐름을 다르게 배치해 리듬감을 만들었습니다.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="travel-albums">
|
||||
{loading ? <p className="travel-state">앨범을 불러오는 중...</p> : null}
|
||||
<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='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <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 && albums.length === 0 ? (
|
||||
<p className="travel-state">표시할 앨범이 없습니다.</p>
|
||||
{!loading && !error && selectedRegion && photos.length === 0 ? (
|
||||
<p className="travel-state">
|
||||
선택한 지역에 사진이 없습니다.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{albums.map((album) => (
|
||||
<div key={album.name} className="travel-album">
|
||||
<div className="travel-album__head">
|
||||
<div>
|
||||
<p className="travel-album__eyebrow">Album</p>
|
||||
<h2>{album.name}</h2>
|
||||
<p className="travel-album__meta">
|
||||
{album.count} photos
|
||||
{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>
|
||||
</div>
|
||||
{album.cover ? (
|
||||
<img
|
||||
className="travel-album__cover"
|
||||
src={album.cover}
|
||||
alt={`${album.name} cover`}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="travel-grid">
|
||||
{album.photos.map((photo, index) => {
|
||||
const label = photo.title || getPhotoLabel(photo.src);
|
||||
return (
|
||||
<article
|
||||
key={`${album.name}-${photo.src}`}
|
||||
className={`travel-card ${
|
||||
index % 6 === 0 ? 'is-wide' : ''
|
||||
}`}
|
||||
>
|
||||
<img src={photo.src} alt={label} loading="lazy" />
|
||||
<div className="travel-card__overlay">
|
||||
<p className="travel-card__title">{label}</p>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
<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>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user