여행 기록 UI/UX 오류 수정

This commit is contained in:
2026-01-26 03:05:38 +09:00
parent d53f581c58
commit 9d8af6b03b
2 changed files with 489 additions and 55 deletions

View File

@@ -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}