689 lines
27 KiB
JavaScript
689 lines
27 KiB
JavaScript
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 (
|
||
<>
|
||
<div className="travel-grid">
|
||
{photos.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}>
|
||
{hasNext ? (
|
||
<button
|
||
type="button"
|
||
className="travel-load-more"
|
||
onClick={onLoadMore}
|
||
disabled={isLoadingMore}
|
||
>
|
||
{isLoadingMore
|
||
? 'Loading more...'
|
||
: `Load more (${photos.length})`}
|
||
</button>
|
||
) : photos.length ? (
|
||
<p className="travel-load-more">모든 사진을 불러왔습니다.</p>
|
||
) : 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 [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 (
|
||
<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='© <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 && selectedRegion && photos.length === 0 ? (
|
||
<p className="travel-state">
|
||
선택한 지역에 사진이 없습니다.
|
||
</p>
|
||
) : null}
|
||
|
||
{!loading && !error && selectedRegion ? (
|
||
<TravelPhotoGrid
|
||
photos={photos}
|
||
regionLabel={selectedRegion.id}
|
||
onSelectPhoto={handleSelectPhoto}
|
||
onLoadMore={loadMorePhotos}
|
||
hasNext={hasNext}
|
||
isLoadingMore={loadingMore}
|
||
/>
|
||
) : 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__stage">
|
||
<button
|
||
type="button"
|
||
className="travel-modal__arrow is-prev"
|
||
onClick={goPrev}
|
||
disabled={selectedPhotoIndex === 0}
|
||
aria-label="Previous"
|
||
>
|
||
{'<'}
|
||
</button>
|
||
<div className="travel-modal__frame">
|
||
<img
|
||
key={`${selectedPhotoIndex}-${slideToken}`}
|
||
className={`travel-modal__image ${
|
||
slideDirection === 'prev'
|
||
? 'is-prev'
|
||
: 'is-next'
|
||
}`}
|
||
src={
|
||
photos[selectedPhotoIndex]?.original ||
|
||
photos[selectedPhotoIndex]?.src
|
||
}
|
||
alt={getPhotoLabel(photos[selectedPhotoIndex])}
|
||
onError={(event) => {
|
||
const img = event.currentTarget;
|
||
const original =
|
||
photos[selectedPhotoIndex]?.original;
|
||
if (original && img.src !== original) {
|
||
img.src = original;
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="travel-modal__arrow is-next"
|
||
onClick={goNext}
|
||
disabled={
|
||
selectedPhotoIndex === photos.length - 1
|
||
}
|
||
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>
|
||
{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;
|
||
|