Files
web-page/src/pages/travel/Travel.jsx

689 lines
27 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, { 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='&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}
{!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;