diff --git a/src/pages/travel/Travel.css b/src/pages/travel/Travel.css index 078ff23..e75d708 100644 --- a/src/pages/travel/Travel.css +++ b/src/pages/travel/Travel.css @@ -71,8 +71,9 @@ } .travel-albums.is-blurred { - filter: blur(3px); - transition: filter 0.2s ease; + opacity: 0.5; + transform: scale(0.995); + transition: opacity 0.2s ease, transform 0.2s ease; } .travel-albums.is-blurred * { @@ -206,6 +207,11 @@ border: 1px solid rgba(255, 255, 255, 0.12); min-height: 220px; cursor: pointer; + opacity: 0; + transform: translateY(22px) scale(0.98); + transition: opacity 0.45s ease, transform 0.45s ease; + transition-delay: var(--reveal-delay, 0ms); + will-change: opacity, transform; } @media (max-width: 768px) { @@ -218,6 +224,11 @@ grid-column: span 2; } +.travel-card[data-revealed='true'] { + opacity: 1; + transform: translateY(0) scale(1); +} + .travel-card img { width: 100%; height: 100%; @@ -247,7 +258,8 @@ .travel-modal { position: fixed; inset: 0; - background: rgba(6, 8, 12, 0.75); + background: rgba(6, 8, 12, 0.55); + backdrop-filter: blur(var(--modal-blur, 6px)); display: grid; align-items: start; justify-items: center; @@ -268,6 +280,17 @@ margin-top: 24px; } +[data-reveal] { + opacity: 0; + transform: translateY(18px); + transition: opacity 0.5s ease, transform 0.5s ease; +} + +[data-reveal][data-revealed='true'] { + opacity: 1; + transform: translateY(0); +} + .travel-modal__summary { display: grid; gap: 6px; @@ -301,6 +324,61 @@ gap: 14px; } +.travel-modal__controls { + display: grid; + gap: 8px; + font-size: 11px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.12em; +} + +.travel-modal__control { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} + +.travel-blur-slider { + display: flex; + align-items: center; + gap: 10px; +} + +.travel-blur-slider input[type='range'] { + appearance: none; + width: 140px; + height: 4px; + background: rgba(255, 255, 255, 0.2); + border-radius: 999px; + outline: none; +} + +.travel-blur-slider input[type='range']::-webkit-slider-thumb { + appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: #f8f4f0; + border: 1px solid rgba(255, 255, 255, 0.5); + cursor: pointer; +} + +.travel-blur-slider input[type='range']::-moz-range-thumb { + width: 14px; + height: 14px; + border-radius: 50%; + background: #f8f4f0; + border: 1px solid rgba(255, 255, 255, 0.5); + cursor: pointer; +} + +.travel-blur-value { + font-size: 11px; + color: var(--muted); +} + .travel-modal__frame { width: 100%; height: 68vh; @@ -327,11 +405,20 @@ animation: travel-slide-prev 280ms ease; } +.travel-modal__strip-wrap { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 8px; +} + .travel-modal__strip { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 6px; + scroll-behavior: smooth; + scrollbar-width: thin; } .travel-modal__thumb { @@ -343,6 +430,7 @@ padding: 0; cursor: pointer; opacity: 0.7; + flex: 0 0 auto; } .travel-modal__thumb img { @@ -358,6 +446,23 @@ border-color: rgba(255, 255, 255, 0.6); } +.travel-modal__strip-arrow { + width: 32px; + height: 32px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.2); + background: rgba(10, 12, 20, 0.7); + color: #f8f4f0; + font-size: 18px; + cursor: pointer; + transition: transform 0.2s ease, border-color 0.2s ease; +} + +.travel-modal__strip-arrow:hover { + transform: translateY(-1px); + border-color: rgba(255, 255, 255, 0.5); +} + .travel-modal__meta { margin: 0; color: var(--muted); @@ -393,6 +498,26 @@ transition: transform 0.2s ease, border-color 0.2s ease; } +.travel-modal__arrow.is-loading { + position: relative; +} + +.travel-modal__arrow-icon { + display: block; +} + +.travel-modal__spinner { + position: absolute; + inset: 0; + margin: auto; + width: 18px; + height: 18px; + border-radius: 50%; + border: 2px solid rgba(255, 255, 255, 0.35); + border-top-color: rgba(255, 255, 255, 0.9); + animation: travel-spin 0.8s linear infinite; +} + .travel-modal__arrow:hover { transform: translateY(-1px) scale(1.02); border-color: rgba(255, 255, 255, 0.5); @@ -423,6 +548,29 @@ } } +@keyframes travel-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.travel-modal__toast { + position: absolute; + left: 24px; + bottom: 20px; + padding: 8px 12px; + border-radius: 999px; + background: rgba(10, 12, 20, 0.85); + border: 1px solid rgba(255, 255, 255, 0.2); + color: #f8f4f0; + font-size: 12px; + letter-spacing: 0.04em; + box-shadow: 0 8px 18px rgba(0, 0, 0, 0.3); +} + @media (max-width: 768px) { .travel-modal__content { padding: 16px; @@ -476,3 +624,12 @@ height: 160px; } } + +@media (prefers-reduced-motion: reduce) { + .travel-card, + [data-reveal] { + opacity: 1 !important; + transform: none !important; + transition: none !important; + } +} diff --git a/src/pages/travel/Travel.jsx b/src/pages/travel/Travel.jsx index 4622e81..f5c348a 100644 --- a/src/pages/travel/Travel.jsx +++ b/src/pages/travel/Travel.jsx @@ -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 ( <> -
+
{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 ( -
-
+
+

Visual Diary

Travel Archive

@@ -503,9 +676,10 @@ const Travel = () => { className={`travel-albums ${ selectedPhotoIndex !== null ? 'is-blurred' : '' }`} + data-reveal >
-
+

Select a region

{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`, + }} >

{ .join(', ')}

) : null} +
+
+ Blur +
+ + setBackdropBlur( + Number(event.target.value) + ) + } + aria-label="Background blur" + /> + + {backdropBlur}px + +
+
+
+ Thumb +
+ + setThumbScrollDuration( + Number(event.target.value) + ) + } + aria-label="Thumbnail scroll speed" + /> + + {thumbScrollDuration}ms + +
+
+
+
+
+ +
+ {photos + .slice(stripStart, stripEnd) + .map((photo, idx) => { + const realIndex = stripStart + idx; + return ( + + ); + })} +
+
-
- {photos - .slice(stripStart, stripEnd) - .map((photo, idx) => { - const realIndex = stripStart + idx; - return ( - - ); - })} -
{photos[selectedPhotoIndex]?.album || photos[selectedPhotoIndex]?.file ? (

@@ -677,6 +949,11 @@ const Travel = () => { : ''}

) : null} + {toastMessage ? ( +
+ {toastMessage} +
+ ) : null}
) : null}