여행 기록 UI/UX 오류 수정
This commit is contained in:
@@ -56,6 +56,8 @@ const TravelPhotoGrid = ({
|
||||
isLoadingMore,
|
||||
}) => {
|
||||
const sentinelRef = useRef(null);
|
||||
const gridRef = useRef(null);
|
||||
const revealObserverRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
@@ -74,9 +76,48 @@ const TravelPhotoGrid = ({
|
||||
return () => observer.disconnect();
|
||||
}, [hasNext, isLoadingMore, onLoadMore]);
|
||||
|
||||
useEffect(() => {
|
||||
revealObserverRef.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting) return;
|
||||
entry.target.dataset.revealed = 'true';
|
||||
revealObserverRef.current?.unobserve(entry.target);
|
||||
});
|
||||
},
|
||||
{ rootMargin: '120px', threshold: 0.15 }
|
||||
);
|
||||
|
||||
return () => revealObserverRef.current?.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = revealObserverRef.current;
|
||||
const grid = gridRef.current;
|
||||
if (!observer || !grid) return;
|
||||
const cards = grid.querySelectorAll(
|
||||
'.travel-card:not([data-revealed="true"])'
|
||||
);
|
||||
cards.forEach((card) => observer.observe(card));
|
||||
const fallback = window.setTimeout(() => {
|
||||
const stillHidden = grid.querySelectorAll(
|
||||
'.travel-card:not([data-revealed="true"])'
|
||||
);
|
||||
if (stillHidden.length) {
|
||||
stillHidden.forEach(
|
||||
(card) => (card.dataset.revealed = 'true')
|
||||
);
|
||||
}
|
||||
}, 500);
|
||||
return () => {
|
||||
window.clearTimeout(fallback);
|
||||
cards.forEach((card) => observer.unobserve(card));
|
||||
};
|
||||
}, [photos.length]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="travel-grid">
|
||||
<div className="travel-grid" ref={gridRef}>
|
||||
{photos.map((photo, index) => {
|
||||
const label = getPhotoLabel(photo);
|
||||
return (
|
||||
@@ -85,6 +126,9 @@ const TravelPhotoGrid = ({
|
||||
className={`travel-card ${
|
||||
index % 6 === 0 ? 'is-wide' : ''
|
||||
}`}
|
||||
style={{
|
||||
'--reveal-delay': `${Math.min(index, 16) * 40}ms`,
|
||||
}}
|
||||
onClick={(event) => onSelectPhoto(index, event)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
@@ -175,10 +219,35 @@ const Travel = () => {
|
||||
const touchStartXRef = useRef(null);
|
||||
const [slideDirection, setSlideDirection] = useState('next');
|
||||
const [slideToken, setSlideToken] = useState(0);
|
||||
const pendingAdvanceRef = useRef(null);
|
||||
const [backdropBlur, setBackdropBlur] = useState(6);
|
||||
const [thumbScrollDuration, setThumbScrollDuration] = useState(360);
|
||||
const [toastMessage, setToastMessage] = useState('');
|
||||
const toastTimerRef = useRef(null);
|
||||
const thumbStripRef = useRef(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasNext, setHasNext] = useState(true);
|
||||
const cacheRef = useRef(new Map());
|
||||
const cacheTtlMs = 10 * 60 * 1000;
|
||||
const travelRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const root = travelRef.current;
|
||||
if (!root) return undefined;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting) return;
|
||||
entry.target.dataset.revealed = 'true';
|
||||
observer.unobserve(entry.target);
|
||||
});
|
||||
},
|
||||
{ rootMargin: '140px' }
|
||||
);
|
||||
const targets = root.querySelectorAll('[data-reveal]');
|
||||
targets.forEach((node) => observer.observe(node));
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
@@ -373,6 +442,24 @@ const Travel = () => {
|
||||
setSlideToken((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const showToast = useCallback((message) => {
|
||||
setToastMessage(message);
|
||||
if (toastTimerRef.current) {
|
||||
window.clearTimeout(toastTimerRef.current);
|
||||
}
|
||||
toastTimerRef.current = window.setTimeout(() => {
|
||||
setToastMessage('');
|
||||
}, 1600);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (toastTimerRef.current) {
|
||||
window.clearTimeout(toastTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
if (selectedPhotoIndex === null || selectedPhotoIndex <= 0) return;
|
||||
bumpSlide('prev');
|
||||
@@ -380,15 +467,46 @@ const Travel = () => {
|
||||
}, [selectedPhotoIndex]);
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
if (
|
||||
selectedPhotoIndex === null ||
|
||||
selectedPhotoIndex >= photos.length - 1
|
||||
) {
|
||||
if (selectedPhotoIndex === null) return;
|
||||
if (selectedPhotoIndex < photos.length - 1) {
|
||||
bumpSlide('next');
|
||||
setSelectedPhotoIndex(selectedPhotoIndex + 1);
|
||||
return;
|
||||
}
|
||||
bumpSlide('next');
|
||||
setSelectedPhotoIndex(selectedPhotoIndex + 1);
|
||||
}, [photos.length, selectedPhotoIndex]);
|
||||
if (hasNext && !loadingMore) {
|
||||
pendingAdvanceRef.current = 'next';
|
||||
loadMorePhotos();
|
||||
return;
|
||||
}
|
||||
if (!hasNext) {
|
||||
showToast('다음 사진 없음');
|
||||
}
|
||||
}, [
|
||||
hasNext,
|
||||
loadMorePhotos,
|
||||
loadingMore,
|
||||
photos.length,
|
||||
selectedPhotoIndex,
|
||||
showToast,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingAdvanceRef.current !== 'next') return;
|
||||
if (selectedPhotoIndex === null) {
|
||||
pendingAdvanceRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (selectedPhotoIndex < photos.length - 1) {
|
||||
bumpSlide('next');
|
||||
setSelectedPhotoIndex((prev) =>
|
||||
prev === null ? prev : prev + 1
|
||||
);
|
||||
pendingAdvanceRef.current = null;
|
||||
}
|
||||
if (!hasNext && selectedPhotoIndex >= photos.length - 1) {
|
||||
pendingAdvanceRef.current = null;
|
||||
}
|
||||
}, [hasNext, photos.length, selectedPhotoIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPhotoIndex === null) return undefined;
|
||||
@@ -453,6 +571,61 @@ const Travel = () => {
|
||||
? [0, 0]
|
||||
: getStripRange(photos.length, selectedPhotoIndex);
|
||||
|
||||
const scrollToX = (element, target, duration) => {
|
||||
if (!element) return;
|
||||
if (
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches ||
|
||||
duration <= 0
|
||||
) {
|
||||
element.scrollLeft = target;
|
||||
return;
|
||||
}
|
||||
const start = element.scrollLeft;
|
||||
const diff = target - start;
|
||||
if (!diff) return;
|
||||
let startTime = null;
|
||||
const ease = (t) => 0.5 - Math.cos(Math.PI * t) / 2;
|
||||
const step = (timestamp) => {
|
||||
if (!startTime) startTime = timestamp;
|
||||
const elapsed = timestamp - startTime;
|
||||
const t = Math.min(elapsed / duration, 1);
|
||||
element.scrollLeft = start + diff * ease(t);
|
||||
if (t < 1) {
|
||||
window.requestAnimationFrame(step);
|
||||
}
|
||||
};
|
||||
window.requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
const scrollThumbs = (direction) => {
|
||||
const strip = thumbStripRef.current;
|
||||
if (!strip) return;
|
||||
const offset = strip.clientWidth * 0.6;
|
||||
const target =
|
||||
strip.scrollLeft + (direction === 'next' ? offset : -offset);
|
||||
scrollToX(strip, target, thumbScrollDuration);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPhotoIndex === null) return;
|
||||
const strip = thumbStripRef.current;
|
||||
if (!strip) return;
|
||||
const target = strip.querySelector(
|
||||
`[data-thumb-index="${selectedPhotoIndex}"]`
|
||||
);
|
||||
if (!target) return;
|
||||
const stripRect = strip.getBoundingClientRect();
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
const currentScroll = strip.scrollLeft;
|
||||
const targetCenter =
|
||||
targetRect.left -
|
||||
stripRect.left +
|
||||
currentScroll +
|
||||
targetRect.width / 2;
|
||||
const nextScroll = targetCenter - stripRect.width / 2;
|
||||
scrollToX(strip, nextScroll, thumbScrollDuration);
|
||||
}, [selectedPhotoIndex, stripStart, stripEnd, thumbScrollDuration]);
|
||||
|
||||
const handleSelectPhoto = (index, event) => {
|
||||
if (selectedPhotoIndex === null) {
|
||||
bumpSlide('next');
|
||||
@@ -482,8 +655,8 @@ const Travel = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="travel">
|
||||
<header className="travel-header">
|
||||
<div className="travel" ref={travelRef}>
|
||||
<header className="travel-header" data-reveal>
|
||||
<div>
|
||||
<p className="travel-kicker">Visual Diary</p>
|
||||
<h1>Travel Archive</h1>
|
||||
@@ -503,9 +676,10 @@ const Travel = () => {
|
||||
className={`travel-albums ${
|
||||
selectedPhotoIndex !== null ? 'is-blurred' : ''
|
||||
}`}
|
||||
data-reveal
|
||||
>
|
||||
<div className="travel-map">
|
||||
<div className="travel-map__info">
|
||||
<div className="travel-map__info" data-reveal>
|
||||
<p className="travel-map__title">Select a region</p>
|
||||
<p className="travel-map__desc">
|
||||
{selectedRegion
|
||||
@@ -557,7 +731,10 @@ const Travel = () => {
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={() => setSelectedPhotoIndex(null)}
|
||||
style={{ '--modal-offset': `${modalOffset}px` }}
|
||||
style={{
|
||||
'--modal-offset': `${modalOffset}px`,
|
||||
'--modal-blur': `${backdropBlur}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="travel-modal__content"
|
||||
@@ -576,6 +753,50 @@ const Travel = () => {
|
||||
.join(', ')}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="travel-modal__controls">
|
||||
<div className="travel-modal__control">
|
||||
<span>Blur</span>
|
||||
<div className="travel-blur-slider">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={16}
|
||||
step={2}
|
||||
value={backdropBlur}
|
||||
onChange={(event) =>
|
||||
setBackdropBlur(
|
||||
Number(event.target.value)
|
||||
)
|
||||
}
|
||||
aria-label="Background blur"
|
||||
/>
|
||||
<span className="travel-blur-value">
|
||||
{backdropBlur}px
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="travel-modal__control">
|
||||
<span>Thumb</span>
|
||||
<div className="travel-blur-slider">
|
||||
<input
|
||||
type="range"
|
||||
min={150}
|
||||
max={1000}
|
||||
step={50}
|
||||
value={thumbScrollDuration}
|
||||
onChange={(event) =>
|
||||
setThumbScrollDuration(
|
||||
Number(event.target.value)
|
||||
)
|
||||
}
|
||||
aria-label="Thumbnail scroll speed"
|
||||
/>
|
||||
<span className="travel-blur-value">
|
||||
{thumbScrollDuration}ms
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -620,54 +841,105 @@ const Travel = () => {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="travel-modal__arrow is-next"
|
||||
className={`travel-modal__arrow is-next ${
|
||||
loadingMore &&
|
||||
hasNext &&
|
||||
selectedPhotoIndex === photos.length - 1
|
||||
? 'is-loading'
|
||||
: ''
|
||||
}`}
|
||||
onClick={goNext}
|
||||
disabled={
|
||||
selectedPhotoIndex === photos.length - 1
|
||||
selectedPhotoIndex === photos.length - 1 &&
|
||||
!hasNext
|
||||
}
|
||||
aria-label="Next"
|
||||
aria-busy={
|
||||
loadingMore &&
|
||||
hasNext &&
|
||||
selectedPhotoIndex === photos.length - 1
|
||||
}
|
||||
>
|
||||
<span className="travel-modal__arrow-icon">
|
||||
{'>'}
|
||||
</span>
|
||||
{loadingMore &&
|
||||
hasNext &&
|
||||
selectedPhotoIndex === photos.length - 1 ? (
|
||||
<span
|
||||
className="travel-modal__spinner"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
<div className="travel-modal__strip-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className="travel-modal__strip-arrow is-prev"
|
||||
onClick={() => scrollThumbs('prev')}
|
||||
aria-label="이전 썸네일"
|
||||
>
|
||||
{'<'}
|
||||
</button>
|
||||
<div
|
||||
className="travel-modal__strip"
|
||||
role="list"
|
||||
ref={thumbStripRef}
|
||||
>
|
||||
{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'
|
||||
: ''
|
||||
}`}
|
||||
data-thumb-index={realIndex}
|
||||
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>
|
||||
<button
|
||||
type="button"
|
||||
className="travel-modal__strip-arrow is-next"
|
||||
onClick={() => scrollThumbs('next')}
|
||||
aria-label="다음 썸네일"
|
||||
>
|
||||
{'>'}
|
||||
</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">
|
||||
@@ -677,6 +949,11 @@ const Travel = () => {
|
||||
: ''}
|
||||
</p>
|
||||
) : null}
|
||||
{toastMessage ? (
|
||||
<div className="travel-modal__toast">
|
||||
{toastMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user