From 085481e104d7ae3e2fe3e4c72542f435071aeede Mon Sep 17 00:00:00 2001 From: gahusb Date: Fri, 24 Apr 2026 01:22:49 +0900 Subject: [PATCH] =?UTF-8?q?feat(travel):=20HeroLightbox=20=E2=80=94=20shar?= =?UTF-8?q?ed=20element=20transition=20+=20=EC=8A=A4=EC=99=80=EC=9D=B4?= =?UTF-8?q?=ED=94=84=20=ED=83=90=EC=83=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- src/pages/travel/HeroLightbox.css | 279 ++++++++++++++++++++++++++++++ src/pages/travel/HeroLightbox.jsx | 268 ++++++++++++++++++++++++++++ 2 files changed, 547 insertions(+) create mode 100644 src/pages/travel/HeroLightbox.css create mode 100644 src/pages/travel/HeroLightbox.jsx diff --git a/src/pages/travel/HeroLightbox.css b/src/pages/travel/HeroLightbox.css new file mode 100644 index 0000000..5e23e6e --- /dev/null +++ b/src/pages/travel/HeroLightbox.css @@ -0,0 +1,279 @@ +/* ═══════════════════════════════════════════ + HeroLightbox — fullscreen photo viewer + ═══════════════════════════════════════════ */ + +/* ── Root overlay ── */ +.hero-lb { + position: fixed; + inset: 0; + z-index: 9000; + display: flex; + align-items: center; + justify-content: center; + transition: opacity 0.35s ease; +} +.hero-lb--enter { opacity: 0; } +.hero-lb--open { opacity: 1; } +.hero-lb--exit { opacity: 0; pointer-events: none; } + +/* ── Backdrop ── */ +.hero-lb__backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.95); + transition: background 0.35s ease; +} +.hero-lb--enter .hero-lb__backdrop { background: rgba(0, 0, 0, 0); } + +/* ── Inner container ── */ +.hero-lb__inner { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + max-width: 1280px; + width: 100%; + height: 100%; + padding: 16px 24px; + box-sizing: border-box; +} + +/* ── Top bar ── */ +.hero-lb__topbar { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 4px 0 8px; + flex-shrink: 0; +} + +.hero-lb__counter { + font-family: var(--tv-mono, 'Space Mono', 'Courier New', monospace); + font-size: 0.85rem; + color: var(--tv-muted, rgba(232, 221, 208, 0.45)); + letter-spacing: 0.04em; +} +.hero-lb__counter-cur { + font-weight: 600; +} + +.hero-lb__close { + width: 40px; + height: 40px; + border-radius: 50%; + border: none; + background: rgba(255, 255, 255, 0.08); + color: var(--tv-text, #e8ddd0); + font-size: 1.4rem; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s; + flex-shrink: 0; +} +.hero-lb__close:hover { + background: rgba(255, 255, 255, 0.18); +} + +/* ── Stage (photo + arrows) ── */ +.hero-lb__stage { + flex: 1 1 auto; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + min-height: 0; + position: relative; + gap: 12px; +} + +/* ── Photo ── */ +.hero-lb__photo { + max-width: 100%; + max-height: calc(100vh - 200px); + object-fit: contain; + border-radius: 4px; + user-select: none; + -webkit-user-drag: none; +} + +/* ── Slide animations ── */ +.hero-lb__slide--next { + animation: hero-slide-right 280ms cubic-bezier(0.22, 0.68, 0.36, 1) both; +} +.hero-lb__slide--prev { + animation: hero-slide-left 280ms cubic-bezier(0.22, 0.68, 0.36, 1) both; +} + +@keyframes hero-slide-right { + from { opacity: 0; transform: translateX(24px); } + to { opacity: 1; transform: translateX(0); } +} +@keyframes hero-slide-left { + from { opacity: 0; transform: translateX(-24px); } + to { opacity: 1; transform: translateX(0); } +} + +/* ── Arrow buttons ── */ +.hero-lb__arrow { + width: 44px; + height: 44px; + border-radius: 12px; + border: none; + background: rgba(255, 255, 255, 0.06); + color: var(--tv-text, #e8ddd0); + font-size: 1.6rem; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: background 0.2s, transform 0.15s; +} +.hero-lb__arrow:hover { + background: rgba(255, 255, 255, 0.14); + transform: scale(1.06); +} +.hero-lb__arrow:active { + transform: scale(0.96); +} +.hero-lb__arrow--loading { + cursor: default; + opacity: 0.6; +} +.hero-lb__arrow--loading:hover { + background: rgba(255, 255, 255, 0.06); + transform: none; +} + +/* ── Spinner ── */ +.hero-lb__spinner { + width: 20px; + height: 20px; + border: 2px solid rgba(255, 255, 255, 0.15); + border-top-color: var(--tv-text, #e8ddd0); + border-radius: 50%; + animation: hero-spin 0.7s linear infinite; +} +@keyframes hero-spin { + to { transform: rotate(360deg); } +} + +/* ── Meta ── */ +.hero-lb__meta { + padding: 8px 0 4px; + font-size: 0.82rem; + color: var(--tv-muted, rgba(232, 221, 208, 0.45)); + text-align: center; + flex-shrink: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} +.hero-lb__meta-album { + font-family: var(--tv-serif, 'Cormorant Garamond', Georgia, serif); + font-style: italic; +} +.hero-lb__meta-file { + font-family: var(--tv-mono, 'Space Mono', 'Courier New', monospace); + font-size: 0.78rem; +} + +/* ── Thumbnail strip ── */ +.hero-lb__strip { + display: flex; + gap: 4px; + overflow-x: auto; + justify-content: center; + padding: 8px 0 4px; + flex-shrink: 0; + max-width: 100%; + scrollbar-width: none; + -ms-overflow-style: none; +} +.hero-lb__strip::-webkit-scrollbar { + display: none; +} + +.hero-lb__thumb { + width: 52px; + height: 52px; + flex-shrink: 0; + border-radius: 4px; + border: 2px solid transparent; + padding: 0; + background: none; + cursor: pointer; + overflow: hidden; + transition: border-color 0.2s, opacity 0.2s; + opacity: 0.55; +} +.hero-lb__thumb:hover { + opacity: 0.85; +} +.hero-lb__thumb--active { + border-color: #f5e6c8; + opacity: 1; +} +.hero-lb__thumb img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + border-radius: 2px; +} + +/* ═══════════════════════════════════════════ + Mobile (<=768px) + ═══════════════════════════════════════════ */ +@media (max-width: 768px) { + .hero-lb__inner { + max-width: 100vw; + padding: 12px 12px; + } + + .hero-lb__arrow { + display: none; + } + + .hero-lb__thumb { + width: 44px; + height: 44px; + } + + .hero-lb__photo { + max-height: calc(100vh - 180px); + } + + .hero-lb__meta { + font-size: 0.76rem; + } +} + +/* ═══════════════════════════════════════════ + Reduced motion + ═══════════════════════════════════════════ */ +@media (prefers-reduced-motion: reduce) { + .hero-lb, + .hero-lb__backdrop, + .hero-lb__close, + .hero-lb__arrow, + .hero-lb__thumb { + transition: none; + } + + .hero-lb__slide--next, + .hero-lb__slide--prev { + animation: none; + } + + .hero-lb__spinner { + animation: none; + } +} diff --git a/src/pages/travel/HeroLightbox.jsx b/src/pages/travel/HeroLightbox.jsx new file mode 100644 index 0000000..4844a10 --- /dev/null +++ b/src/pages/travel/HeroLightbox.jsx @@ -0,0 +1,268 @@ +import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import { useSwipeable } from 'react-swipeable'; +import { useIsMobile } from '../../hooks/useIsMobile'; +import { getRegionAccent } from './MiniMap'; +import './HeroLightbox.css'; + +/* ───────────────────────────────────────────── + Helpers +───────────────────────────────────────────── */ +const STRIP_LIMIT = 36; +const THUMB_SIZE = 52; +const THUMB_SIZE_MOBILE = 44; +const ANIM_MS = 350; + +function getStripRange(total, active) { + if (total <= STRIP_LIMIT) return [0, total]; + const half = Math.floor(STRIP_LIMIT / 2); + let start = active - half; + if (start < 0) start = 0; + let end = start + STRIP_LIMIT; + if (end > total) { + end = total; + start = Math.max(0, end - STRIP_LIMIT); + } + return [start, end]; +} + +const prefersReduced = () => + typeof window !== 'undefined' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches; + +/* ───────────────────────────────────────────── + HeroLightbox +───────────────────────────────────────────── */ +export default function HeroLightbox({ + photos, + selectedIndex, + albumName, + regionId, + sourceRect, + hasNext, + loadingMore, + onClose, + onNavigate, + onLoadMore, +}) { + const isMobile = useIsMobile(); + const [phase, setPhase] = useState('enter'); + const [slideDir, setSlideDir] = useState(null); + const [slideToken, setSlideToken] = useState(0); + const pendingAdvanceRef = useRef(false); + const stripRef = useRef(null); + const prevOverflowRef = useRef(''); + const accent = useMemo(() => getRegionAccent(regionId), [regionId]); + const reduced = useMemo(() => prefersReduced(), []); + const animMs = reduced ? 0 : ANIM_MS; + + /* — Phase transitions — */ + useEffect(() => { + // enter → open via double rAF + let raf1, raf2; + raf1 = requestAnimationFrame(() => { + raf2 = requestAnimationFrame(() => setPhase('open')); + }); + return () => { + cancelAnimationFrame(raf1); + cancelAnimationFrame(raf2); + }; + }, []); + + /* — Body scroll lock — */ + useEffect(() => { + prevOverflowRef.current = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = prevOverflowRef.current; + }; + }, []); + + /* — Pending advance after load more — */ + useEffect(() => { + if (pendingAdvanceRef.current && !loadingMore) { + pendingAdvanceRef.current = false; + goNext(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [loadingMore, photos.length]); + + /* — Auto-center active thumb — */ + useEffect(() => { + if (!stripRef.current) return; + const thumbSize = isMobile ? THUMB_SIZE_MOBILE : THUMB_SIZE; + const gap = 4; + const stripW = stripRef.current.offsetWidth; + const scrollTarget = + selectedIndex * (thumbSize + gap) - stripW / 2 + thumbSize / 2; + stripRef.current.scrollTo({ left: scrollTarget, behavior: reduced ? 'auto' : 'smooth' }); + }, [selectedIndex, isMobile, reduced]); + + /* — Close handler — */ + const handleClose = useCallback(() => { + setPhase('exit'); + setTimeout(onClose, animMs); + }, [onClose, animMs]); + + /* — Navigation — */ + const goPrev = useCallback(() => { + if (selectedIndex <= 0) return; + setSlideDir('prev'); + setSlideToken((t) => t + 1); + onNavigate(selectedIndex - 1); + }, [selectedIndex, onNavigate]); + + const goNext = useCallback(() => { + if (selectedIndex >= photos.length - 1) { + if (hasNext) { + pendingAdvanceRef.current = true; + onLoadMore?.(); + } + return; + } + setSlideDir('next'); + setSlideToken((t) => t + 1); + onNavigate(selectedIndex + 1); + }, [selectedIndex, photos.length, hasNext, onNavigate, onLoadMore]); + + /* — Keyboard — */ + useEffect(() => { + const handler = (e) => { + if (e.key === 'Escape') handleClose(); + else if (e.key === 'ArrowLeft') goPrev(); + else if (e.key === 'ArrowRight') goNext(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [handleClose, goPrev, goNext]); + + /* — Swipe — */ + const swipeHandlers = useSwipeable({ + onSwipedLeft: goNext, + onSwipedRight: goPrev, + onSwipedDown: (e) => { + if (e.absY > 100) handleClose(); + }, + trackMouse: false, + delta: 30, + }); + + /* — Current photo — */ + const photo = photos[selectedIndex]; + if (!photo) return null; + + const thumbSz = isMobile ? THUMB_SIZE_MOBILE : THUMB_SIZE; + const [stripStart, stripEnd] = getStripRange(photos.length, selectedIndex); + const stripPhotos = photos.slice(stripStart, stripEnd); + + const slideClass = + slideDir === 'next' + ? 'hero-lb__slide--next' + : slideDir === 'prev' + ? 'hero-lb__slide--prev' + : ''; + + return ( +
+ {/* Backdrop */} +
+ + {/* Inner */} +
+ {/* Top bar */} +
+ + + {selectedIndex + 1} + + {' / '} + {photos.length} + + +
+ + {/* Main photo area */} +
+ {/* Left arrow */} + {!isMobile && selectedIndex > 0 && ( + + )} + + {/* Photo */} + {photo.filename + + {/* Right arrow */} + {!isMobile && selectedIndex < photos.length - 1 && ( + + )} + + {/* Loading spinner for load-more */} + {!isMobile && loadingMore && selectedIndex >= photos.length - 1 && ( + + )} +
+ + {/* Meta */} +
+ {albumName} + {' · '} + {photo.filename || photo.name || ''} +
+ + {/* Thumbnail strip */} +
+ {stripPhotos.map((p, i) => { + const realIdx = stripStart + i; + const isActive = realIdx === selectedIndex; + return ( + + ); + })} +
+
+
+ ); +}